From 86147788cd7828cc2ce85717ebbffaa8df60a7dc Mon Sep 17 00:00:00 2001 From: Elizabeth Paige Harper Date: Thu, 25 Aug 2022 10:12:08 -0400 Subject: [PATCH] Initial release. (#5) --- lib/build.gradle.kts | 80 +++++ .../MultipartApplicationEventListener.kt | 41 +++ .../multipart/MultipartMessageBodyReader.kt | 279 ++++++++++++++++++ .../MultipartRequestEventListener.kt | 58 ++++ .../lib/jaxrs/raml/multipart/consts.kt | 13 + .../multipart/utils/CappedOutputStream.kt | 24 ++ .../lib/jaxrs/raml/multipart/utils/apache.kt | 109 +++++++ .../lib/jaxrs/raml/multipart/utils/cls.kt | 94 ++++++ .../lib/jaxrs/raml/multipart/utils/jersey.kt | 32 ++ .../multipart/utils/multipart-requests.kt | 40 +++ readme.adoc | 89 ++++++ settings.gradle.kts | 3 + test/build.gradle.kts | 46 +++ test/src/main/java/derp/Controller.java | 35 +++ .../main/java/derp/EnumWithAnnotations.java | 18 ++ .../main/java/derp/EnumWithConstructor.java | 22 ++ test/src/main/java/derp/ErrorMapper.kt | 19 ++ test/src/main/java/derp/Main.java | 20 ++ test/src/main/java/derp/Model.java | 57 ++++ test/src/main/java/derp/Resources.java | 21 ++ 20 files changed, 1100 insertions(+) create mode 100644 lib/build.gradle.kts create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartApplicationEventListener.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartMessageBodyReader.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartRequestEventListener.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/consts.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/CappedOutputStream.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/apache.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/cls.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/jersey.kt create mode 100644 lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/multipart-requests.kt create mode 100644 readme.adoc create mode 100644 settings.gradle.kts create mode 100644 test/build.gradle.kts create mode 100644 test/src/main/java/derp/Controller.java create mode 100644 test/src/main/java/derp/EnumWithAnnotations.java create mode 100644 test/src/main/java/derp/EnumWithConstructor.java create mode 100644 test/src/main/java/derp/ErrorMapper.kt create mode 100644 test/src/main/java/derp/Main.java create mode 100644 test/src/main/java/derp/Model.java create mode 100644 test/src/main/java/derp/Resources.java diff --git a/lib/build.gradle.kts b/lib/build.gradle.kts new file mode 100644 index 0000000..9b483e8 --- /dev/null +++ b/lib/build.gradle.kts @@ -0,0 +1,80 @@ +plugins { + kotlin("jvm") version "1.7.0" + `maven-publish` +} + +group = "org.veupathdb.lib" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + + withJavadocJar() + withSourcesJar() +} + +dependencies { + implementation(kotlin("stdlib")) + implementation(kotlin("stdlib-jdk7")) + implementation(kotlin("stdlib-jdk8")) + + implementation("commons-fileupload:commons-fileupload:1.4") + + implementation("org.glassfish.jersey.containers:jersey-container-grizzly2-http:3.0.5") + implementation("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:3.0.5") + runtimeOnly("org.glassfish.jersey.inject:jersey-hk2:3.0.5") + implementation("org.glassfish.hk2:hk2-api:3.0.3") + + implementation("com.fasterxml.jackson.core:jackson-databind:2.13.3") + implementation("com.fasterxml.jackson.core:jackson-annotations:2.13.3") + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.0") +} + +tasks.getByName("test") { + useJUnitPlatform() +} + +publishing { + repositories { + maven { + name = "GitHub" + url = uri("https://maven.pkg.github.com/VEuPathDB/lib-jersey-multipart-jackson-pojo") + credentials { + username = project.findProperty("gpr.user") as String? ?: System.getenv("USERNAME") + password = project.findProperty("gpr.key") as String? ?: System.getenv("TOKEN") + } + } + } + + publications { + create("gpr") { + from(components["java"]) + pom { + name.set("JaxRS Multipart for Jackson POJOs") + description.set("Support for parsing Jackson POJOs from multipart/form-data request bodies.") + url.set("https://github.com/VEuPathDB/lib-jersey-multipart-jackson-pojo") + developers { + developer { + id.set("epharper") + name.set("Elizabeth Paige Harper") + email.set("epharper@upenn.edu") + url.set("https://github.com/foxcapades") + organization.set("VEuPathDB") + } + } + scm { + connection.set("scm:git:git://github.com/VEuPathDB/lib-jersey-multipart-jackson-pojo.git") + developerConnection.set("scm:git:ssh://github.com/VEuPathDB/lib-jersey-multipart-jackson-pojo.git") + url.set("https://github.com/VEuPathDB/lib-jersey-multipart-jackson-pojo") + } + } + } + } +} diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartApplicationEventListener.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartApplicationEventListener.kt new file mode 100644 index 0000000..8edf290 --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartApplicationEventListener.kt @@ -0,0 +1,41 @@ +package org.veupathdb.lib.jaxrs.raml.multipart + +import jakarta.ws.rs.ext.Provider +import org.glassfish.jersey.server.monitoring.ApplicationEvent +import org.glassfish.jersey.server.monitoring.ApplicationEventListener +import org.glassfish.jersey.server.monitoring.RequestEvent +import org.veupathdb.lib.jaxrs.raml.multipart.utils.isMultipart + +/** + * Application level event listener for the multipart plugin. + * + * This class must be registered with the container resources. + * + * This listener provides [MultipartRequestEventListener] instances for requests + * that are sent in with the `multipart/form-data` Content-Type header set. + * + * @author Elizabeth Paige Harper - https://github.com/foxcapades + * @since 1.0.0 + */ +@Provider +class MultipartApplicationEventListener : ApplicationEventListener { + + /** + * Does nothing. + */ + override fun onEvent(event: ApplicationEvent) {} + + /** + * Returns a new [MultipartRequestEventListener] if the incoming request has + * a `multipart/form-data` Content-Type header set, otherwise returns `null`. + * + * @param requestEvent Request start event. + * + * @return Request event listener or `null`. + */ + override fun onRequest(requestEvent: RequestEvent) = + if (requestEvent.isMultipart()) + MultipartRequestEventListener(requestEvent) + else + null +} \ No newline at end of file diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartMessageBodyReader.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartMessageBodyReader.kt new file mode 100644 index 0000000..aba3a8d --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartMessageBodyReader.kt @@ -0,0 +1,279 @@ +package org.veupathdb.lib.jaxrs.raml.multipart + +import com.fasterxml.jackson.databind.ObjectMapper +import jakarta.ws.rs.BadRequestException +import jakarta.ws.rs.InternalServerErrorException +import jakarta.ws.rs.core.MediaType +import jakarta.ws.rs.core.MultivaluedMap +import jakarta.ws.rs.ext.MessageBodyReader +import jakarta.ws.rs.ext.Provider +import org.apache.commons.fileupload.MultipartStream +import org.veupathdb.lib.jaxrs.raml.multipart.utils.* +import java.io.File +import java.io.InputStream +import java.lang.reflect.Type + +private const val DefaultFileName = "upload" + +private const val BufferSize = 8192 + +/** + * Multipart Form-Data Message Body Reader + * + * JaxRS [MessageBodyReader] implementation that handles parsing + * `multipart/form-data` request bodies into target POJO types. + * + * The target POJO type is defined as the request entity input parameter on the + * service controller method handling the `multipart/form-data` request. + * + * @author Elizabeth Paige Harper - https://github.com/foxcapades + * @since 1.0.0 + */ +@Provider +class MultipartMessageBodyReader : MessageBodyReader { + + /** + * Tests whether this `MessageBodyReader` should kick in for the given + * request. + * + * @return `true` if the input request is of type `multipart/form-data`, + * otherwise `false`. + */ + override fun isReadable( + type: Class<*>, + genericType: Type, + annotations: Array, + mediaType: MediaType, + ) = mediaType.isCompatible(MediaType.MULTIPART_FORM_DATA_TYPE) + + + /** + * Parses a value of the target [type] from the given input + * [entity][entityStream]. + * + * @return A value of the target [type] parsed from one or more parts of the + * multipart input stream. + */ + override fun readFrom( + type: Class, + genericType: Type, + annotations: Array, + mediaType: MediaType, + httpHeaders: MultivaluedMap, + entityStream: InputStream, + ): Any { + // Open a stream over the contents of the multipart/form-data body. + val stream = MultipartStream(entityStream, mediaType.requireBoundaryBytes(), BufferSize, null) + + // Get hold of the temp directory assigned to this multipart request. + val tmpDir = httpHeaders.getTempDirectory() + ?: throw InternalServerErrorException("Multipart request made it to the MessageBodyReader with no temp directory") + + // If the method just takes a raw file as an input: + if (File::class.java.isAssignableFrom(type)) + return fileReadFrom(stream, tmpDir) + .also { entityStream.close() } + + // If the method takes an Enum type as an input: + if (type.isEnum) + return enumReadFrom(type, stream) + .also { entityStream.close() } + + // TODO: Map and Collection? + + // Else, assume the type is a POJO and deserialize accordingly: + return pojoReadFrom(type, stream, tmpDir) + .also { entityStream.close() } + } + + /** + * Reads the contents of the first segment of the input body into a file then + * returns that file. + */ + private fun fileReadFrom(stream: MultipartStream, tmpDir: File): File { + // Skip over any extra stuff to get to the first content section. + stream.skipPreamble() + || throw BadRequestException("Missing body content.") + + // Get the content disposition information from the section headers. + val cont = stream.parseHeaders() + .requireContentDisposition() + + // Get the file name or provide a name for the file if no filename was sent + // in with the request. + val fileName = cont.getFileName() ?: cont.getFormName() ?: DefaultFileName + + // Create the upload file and populate it with the contents of the stream. + return File(tmpDir, fileName).apply { + createNewFile() + outputStream().use { stream.readBodyData(it) } + } + } + + /** + * Attempts to parse the contents of the first segment of the input body as + * the type expected by the static "constructor" of the given enum [type]. + * + * The given enum [type] must have a static "constructor" annotated with the + * Jackson `@JsonCreator` annotation. + * + * If no such annotated static method is found on the type this method will + * throw a 500 exception. + * + * @param type Class for the enum type that should be instantiated. + * + * @param stream Incoming request body. + * + * @return A new instance of the given enum [type] parsed from the first + * segment of the input body. + */ + private fun enumReadFrom(type: Class, stream: MultipartStream): Any { + // Skip over the prefix information, headers, etc... + stream.skipPreamble() + || throw BadRequestException("Missing body content.") + + // Skip over the headers + stream.readHeaders() + + // Attempt to locate a static method to use as the jackson "constructor" + // (will be annotated with @JsonCreator) to get an instance of the type from + // the value. + val constructor = type.getJacksonConstructor() + + if (constructor != null) { + // Attempt to read the first part of the body as the generic type defined by + // the constructor method's input parameter. + val inp = mapper.convertValue( + stream.readContentAsJsonNode(maxVariableSize), + mapper.typeFactory.constructType(constructor.genericParameterTypes[0]) + ) + + // Return the result of calling the target method. + return constructor.invoke(null, inp) + } + + // There was no static @JsonCreator annotated method to use to get hold of + // an instance of the enum. + + // Look for fields annotated with @JsonProperty as a secondary approach to + // trying to get an enum value. + val fields = type.getJacksonAnnotatedEnumProperties() + + if (fields.isNotEmpty()) { + // Get the size of the largest valid key + val largestKeyLength = fields.keys.stream() + .map { it.toByteArray() } + .mapToInt { it.size } + .max() + .asInt + + val inp = stream.contentToString(largestKeyLength) + + if (inp in fields) + return fields[inp]!! + } + + throw InternalServerErrorException("Cannot construct instance of $type from multipart/form-data input.") + } + + /** + * Attempts to parse the contents of the full input body by mapping the parts + * to setters on the pojo that are annotated with Jackson's `@JsonSetter` or + * `@JsonProperty` annotations. + * + * @param type Type of the POJO that will be parsed from the multipart body. + * + * @param stream Incoming request body. + * + * @param tmpDir Temporary directory for this request. This directory will be + * used to hold any files associated with the request. Files are only needed + * in the case when the target POJO defines one or more fields that are of + * type `File`. + * + * @return The parsed POJO value. + */ + private fun pojoReadFrom(type: Class, stream: MultipartStream, tmpDir: File): Any { + // NOTE: We don't try and deserialize into the pojo itself because it may be + // an interface, an enum, or a pojo with zero no-arg constructors. + val temp = HashMap() + + // Skip over the initial header info that is not needed for our parsing. + // + // If this method returns false, then there is nothing in the body, so we + // can attempt to convert our empty map to the pojo now and bail. + if (!stream.skipPreamble()) + return mapper.convertValue(temp, type) + + // Get a map of the fields on the POJO and their types. + val fields = type.fieldsMap() + + do { + // Parse the headers for the current body part. + val headers = stream.parseHeaders() + + // Get the content-disposition header. + val contentDisp = headers.getContentDisposition() + ?: throw BadRequestException("Bad multipart section, missing Content-Disposition header.") + + // Get the name of the form field. this name will be used to map the + // field to the POJO setter. + val fieldName = contentDisp.getFormName() + ?: throw BadRequestException("Bad multipart section, no form field name provided.") + + // If the target field is of type `File`... + if (fields[fieldName] == File::class.java) { + // Figure out what we should name the file. Prefer the original file + // name if available, otherwise use the form field name. + val fileName = contentDisp.getFileName() + ?: fieldName + + // Copy the data from the body part into a temp file to back the POJO + // field. + val file = File(tmpDir, fileName).apply { + createNewFile() + outputStream().use { stream.readBodyData(it) } + } + + // Assign the file value to our temp map. + temp[fieldName] = file + + } else { + // Do regular parse + temp[fieldName] = stream.readContentAsJsonNode(maxVariableSize) + } + + } while (stream.readBoundary()) + + // Iterate through parts in the multipart input + // for each part + // map the part to a field on the POJO type + // if the pojo type is type file, pipe part to temp file and assign prop + // else parse pojo inline and assign prop + return mapper.convertValue(temp, type) + } + + @Suppress("NOTHING_TO_INLINE") + private inline fun MediaType.requireBoundary() = parameters["boundary"] + ?: throw BadRequestException("Content-Type header missing boundary string.") + + @Suppress("NOTHING_TO_INLINE") + private inline fun MediaType.requireBoundaryBytes() = requireBoundary().toByteArray() + + companion object { + + // Jackson + // TODO: Consider using the jackson singleton library instead of having this + // separate mapper that requires configuration. + @JvmStatic + var mapper = ObjectMapper() + + /** + * Max size for a single non-file field that will be read into memory as + * part of a POJO. + * + * Defaults to 16MiB. + */ + @JvmStatic + var maxVariableSize = 16_777_216 + } +} diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartRequestEventListener.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartRequestEventListener.kt new file mode 100644 index 0000000..9e5d1b5 --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/MultipartRequestEventListener.kt @@ -0,0 +1,58 @@ +package org.veupathdb.lib.jaxrs.raml.multipart + +import org.glassfish.jersey.server.monitoring.RequestEvent +import org.glassfish.jersey.server.monitoring.RequestEventListener +import org.veupathdb.lib.jaxrs.raml.multipart.utils.createTempDirectory +import org.veupathdb.lib.jaxrs.raml.multipart.utils.deleteTempDirectory +import org.veupathdb.lib.jaxrs.raml.multipart.utils.isMultipart + +/** + * Multipart Request Event Listener + * + * Jersey request event listener that assigns a temporary directory to a request + * on request start, then removes that directory on request finish. + * + * On creation of this event listener, a temp directory will be created for the + * request that this listener is attached to. On request completion, this + * listener will remove the created temp directory and any of it's remaining + * contents. + * + * @author Elizabeth Paige Harper - https://github.com/foxcapades + * @since 1.0.0 + */ +class MultipartRequestEventListener(event: RequestEvent) : RequestEventListener { + + // We do this on init because the `START` request event is never actually + // passed to the `onEvent` method, the creation of the event listener _is_ the + // `START` event notification. + init { + onRequestStart(event) + } + + /** + * Method called on request event. + * + * This method does nothing unless the given event is a `FINISHED` event which + * signifies the Jersey request processing has completed. + * + * If the given event _is_ a `FINISHED` event, this method will attempt to + * remove the temp directory associated with the request by this event + * listener. + * + * @param event Request event. + */ + override fun onEvent(event: RequestEvent) { + if (event.type == RequestEvent.Type.FINISHED) + onRequestEnd(event) + } + + private fun onRequestStart(event: RequestEvent) { + if (event.isMultipart()) + event.containerRequest.headers.add(TempDirHeader, event.containerRequest.createTempDirectory().path) + } + + private fun onRequestEnd(event: RequestEvent) { + if (event.isMultipart()) + event.containerRequest.deleteTempDirectory() + } +} \ No newline at end of file diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/consts.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/consts.kt new file mode 100644 index 0000000..eee9bd4 --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/consts.kt @@ -0,0 +1,13 @@ +package org.veupathdb.lib.jaxrs.raml.multipart + +/** + * Extra header injected to pass information about the multipart/form-data + * request's attached temp directory through to the `MessageBodyReader`. + * + * This is done because `MessageBodyReader` instances must be singletons and + * cannot have request context information injected into them. This means that + * they cannot have access to a `ContainerRequest` instance and thus cannot + * access the custom properties on that instance (where the temp directory + * reference is stored). + */ +internal const val TempDirHeader = "X-Multipart-Form-Data-Temp-Directory" \ No newline at end of file diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/CappedOutputStream.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/CappedOutputStream.kt new file mode 100644 index 0000000..d054c4c --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/CappedOutputStream.kt @@ -0,0 +1,24 @@ +package org.veupathdb.lib.jaxrs.raml.multipart.utils + +import jakarta.ws.rs.BadRequestException +import java.io.OutputStream + +/** + * Capped Size Output Stream + * + * A simple output stream wrapper that throws an exception if more than the + * specified max number of bytes is read. + */ +class CappedOutputStream( + private val maxBytes: Int, + private val stream: OutputStream +) : OutputStream() { + private var written = 0 + + override fun write(b: Int) { + if (++written > maxBytes) + throw BadRequestException("Form field exceeded maximum allowed number of bytes: $maxBytes") + + stream.write(b) + } +} \ No newline at end of file diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/apache.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/apache.kt new file mode 100644 index 0000000..a1e9bc8 --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/apache.kt @@ -0,0 +1,109 @@ +package org.veupathdb.lib.jaxrs.raml.multipart.utils + +import com.fasterxml.jackson.core.JsonParseException +import jakarta.ws.rs.BadRequestException +import org.apache.commons.fileupload.MultipartStream +import org.veupathdb.lib.jaxrs.raml.multipart.MultipartMessageBodyReader +import java.io.ByteArrayOutputStream + +private val NewLineRGX = Regex("\r\n") +private val HeadSepRGX = Regex(": *") +private val PartSepRGX = Regex("; +") + +private const val LCContentDisp = "content-disposition" +private const val FormNamePrefix = "name=" +private const val FileNamePrefix = "filename=" + + +internal fun MultipartStream.contentToString(maxSize: Int) = + ByteArrayOutputStream(8192).let { + if (readBodyData(CappedOutputStream(maxSize, it)) == 0) + "" + else + it.toByteArray().decodeToString() + } + + +internal inline fun MultipartStream.parseHeaders() = readHeaders().parseHeaders() + +internal fun String?.parseHeaders(): Map> { + if (this == null) + return emptyMap() + + val out = HashMap>(2) + + for (headerLine in split(NewLineRGX)) { + // Sometimes empty lines creep in here, filter them out so that we don't 400 + if (headerLine.isBlank()) + continue + + if (headerLine.indexOf(':') < 1) + throw BadRequestException("Malformed multipart/form-data body part header.") + + val parts = headerLine.split(HeadSepRGX, 2) + out[parts[0]] = parts[1].split(PartSepRGX) + } + + return out +} + +internal fun MultipartStream.readContentAsJsonNode(maxSize: Int) = + contentToString(maxSize).let { + try { + MultipartMessageBodyReader.mapper.readTree(it) + } catch (e: JsonParseException) { + MultipartMessageBodyReader.mapper.readTree("\"$it\"") + } + } + +/** + * Attempts to retrieve the form field name from the receiver header value list. + * + * @return The form name value as parsed from the headers or `null` if no form + * name value was provided. + */ +@Suppress("NOTHING_TO_INLINE") +internal inline fun List.getFormName() = + find { it.startsWith(FormNamePrefix) } + ?.substring(FormNamePrefix.length) + ?.removeQuotes() + +/** + * Attempts to retrieve the file name from the receiver header value list. + * + * @return The file name value as parsed from the headers or `null` if no file + * name value was provided. + */ +@Suppress("NOTHING_TO_INLINE") +internal inline fun List.getFileName() = + find { it.startsWith(FileNamePrefix) } + ?.substring(FileNamePrefix.length) + ?.removeQuotes() + +/** + * Removes a single pair of double quotes from the start and end of the receiver + * string only if such a pair of quotes exists. + * + * @return The receiver string which may have had a single double quote + * character removed from both the start and end of the string. + */ +@Suppress("NOTHING_TO_INLINE") +internal inline fun String.removeQuotes() = + if (get(0) == '"' && get(lastIndex) == '"') + substring(1, lastIndex) + else + this + +/** + * Retrieves the `Content-Disposition` header from the receiver map, if such an + * entry exists. + * + * @return The values of the `Content-Disposition` header, if it was set, + * otherwise `null`. + */ +@Suppress("NOTHING_TO_INLINE") +internal inline fun Map>.getContentDisposition() = + get(keys.find { it.lowercase() == LCContentDisp }) + +internal inline fun Map>.requireContentDisposition() = + getContentDisposition() ?: throw BadRequestException("Multipart section missing Content-Disposition header.") diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/cls.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/cls.kt new file mode 100644 index 0000000..640b9fb --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/cls.kt @@ -0,0 +1,94 @@ +package org.veupathdb.lib.jaxrs.raml.multipart.utils + +import com.fasterxml.jackson.annotation.JsonCreator +import com.fasterxml.jackson.annotation.JsonProperty +import com.fasterxml.jackson.annotation.JsonSetter +import java.lang.reflect.Method +import java.lang.reflect.Modifier + +internal fun Class.fieldsMap(): Map> { + val out = HashMap>(1) + + // Look through all the methods on the class for our targets + for (method in methods) { + if (method.isStatic()) + continue + + // If the method doesn't take a single parameter it's not a plain setter, + // and we can ignore it. + if (method.parameterCount != 1) + continue + + // If the method isn't a setter, ignore it. + if (!method.name.startsWith("set")) + continue + + // Get the jackson annotated field name (or ignore the field if it has + // no such name). + out.put(method.getJacksonName() ?: continue, method.parameters[0].type) + } + + return out +} + +internal fun Class.getJacksonConstructor(): Method? { + + for (method in methods) { + // Sift out any non-static methods + if (!method.isStatic()) + continue + + // Sift out any methods that don't take exactly one parameter + if (method.parameterCount != 1) + continue + + // Sift out any methods that don't have the JsonCreator annotation + if (!method.hasJacksonCreatorAnnotation()) + continue + + // Find the first method that returns a type compatible with this type. + if (isAssignableFrom(method.returnType)) + return method + } + + return null +} + +internal fun Class.getJacksonAnnotatedEnumProperties(): Map { + val out = HashMap(enumConstants.size) + + for (field in declaredFields) { + if (!field.isEnumConstant) + continue + + for (ann in field.annotations) { + if (ann is JsonProperty) { + out[ann.value] = field + } + } + } + + return out +} + +private fun Method.isStatic() = Modifier.isStatic(modifiers) + +private fun Method.getJacksonName(): String? { + for (ann in annotations) { + if (ann is JsonSetter) + return ann.value + if (ann is JsonProperty) + return ann.value + } + + return null +} + +private fun Method.hasJacksonCreatorAnnotation(): Boolean { + for (annotation in annotations) { + if (annotation is JsonCreator) + return true + } + + return false +} \ No newline at end of file diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/jersey.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/jersey.kt new file mode 100644 index 0000000..1ea8f6a --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/jersey.kt @@ -0,0 +1,32 @@ +@file:Suppress("NOTHING_TO_INLINE") + +package org.veupathdb.lib.jaxrs.raml.multipart.utils + +import jakarta.ws.rs.core.MediaType +import org.glassfish.jersey.server.ContainerRequest +import org.glassfish.jersey.server.monitoring.RequestEvent + + +/** + * Tests whether the receiver `RequestEvent` is for a `multipart/form-data` + * request. + * + * @receiver Event to test. + * + * @return `true` if the request is a `multipart/form-data` request, otherwise + * `false`. + */ +internal inline fun RequestEvent.isMultipart() = + containerRequest.isMultipart() + +/** + * Tests whether the receiver `ContainerRequest` is for a `multipart/form-data` + * request. + * + * @receiver Request to test. + * + * @return `true` if the request is a `multipart/form-data` request, otherwise + * `false`. + */ +internal inline fun ContainerRequest.isMultipart() = + mediaType.isCompatible(MediaType.MULTIPART_FORM_DATA_TYPE) \ No newline at end of file diff --git a/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/multipart-requests.kt b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/multipart-requests.kt new file mode 100644 index 0000000..510d400 --- /dev/null +++ b/lib/src/main/java/org/veupathdb/lib/jaxrs/raml/multipart/utils/multipart-requests.kt @@ -0,0 +1,40 @@ +@file:JvmName("MultipartRequests") + +package org.veupathdb.lib.jaxrs.raml.multipart.utils + +import jakarta.ws.rs.InternalServerErrorException +import jakarta.ws.rs.core.MultivaluedMap +import org.glassfish.jersey.server.ContainerRequest +import org.veupathdb.lib.jaxrs.raml.multipart.TempDirHeader +import java.io.File +import java.util.UUID + +internal const val TempDirProperty = "temp-dir-path" + +var tempDirLocation: File = File("/tmp") + +var tempDirPrefix: String = "multipart_" + +fun MultivaluedMap.getTempDirectory() = + get(TempDirHeader)?.get(0)?.let { File(it) } + +fun ContainerRequest.getTempDirectory() = + getProperty(TempDirProperty) as File + +fun ContainerRequest.deleteTempDirectory() = + getTempDirectory().deleteRecursively() + +internal fun ContainerRequest.createTempDirectory(): File { + try { + val tmpDir = File(tempDirLocation, tempDirPrefix + UUID.randomUUID().toString()) + + if (!tmpDir.mkdir()) + throw IllegalStateException("temp directory with path $tmpDir was not created or already exists") + + setProperty(TempDirProperty, tmpDir) + + return tmpDir + } catch (e: Exception) { + throw InternalServerErrorException("Failed to create temporary directory for multipart/form-data request.", e) + } +} \ No newline at end of file diff --git a/readme.adoc b/readme.adoc new file mode 100644 index 0000000..77b8321 --- /dev/null +++ b/readme.adoc @@ -0,0 +1,89 @@ += Jersey Multipart for Jackson POJOs + +Jersey message body reader and event listener for parsing `multipart/form-data` +requests into Jackson annotated POJOs. + +== Usage + +[source, java] +---- +register(MultipartMessageBodyReader.class); +register(MultipartApplicationEventListener.class); +---- + + +=== Example + +.*Controller Method* +[source, java] +---- +@POST +@Consumes(MediaType.MULTIPART_FORM_DATA) +public void myController(SomePOJO pojo) { + ... +} +---- + +.*Input POJO* +[source, java] +---- +public class SomePOJO { + @JsonSetter("foo") + public void setFoo(String foo) { ... } + + @JsonSetter("bar") + public void setBar(File bar) { ... } +} +---- + +== Development + +=== Testing + +A test server using this library can be spun up using the command: + +[source, shell] +---- +./gradlew run +---- + +Once this server is online, you may make multipart `POST` requests to the +service at `localhost:8080` using the following endpoints: + +`/model`:: +Logs the properties on the model class parsed from the input request. ++ +This method expects the form params `foo`, `bar`, and `fizz` as per the +link:test/src/main/java/derp/Model.java[model class] that the quest will be +parsed into. ++ +.*Model Class Fields* +-- +[cols="1m,1,4"] +|=== +| Field | Allowed Values | Description + +| foo +| Any String +| A string parameter. + +| bar +| Any file or value +| A file parameter. + +| fizz +m| "some value" +| An enum parameter +|=== +-- + +`/enum`:: +Logs the enum value parsed from the input request. ++ +This method expects at least 1 form parameter with any field name that will be +parsed into a value on the +link:test/src/main/java/derp/SomeEnum.java[test enum class]. ++ +See the test enum class definition for permissible values. + + diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..d15a9ef --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "jaxrs-multipart-jackson-pojo" + +include(":lib", ":test") \ No newline at end of file diff --git a/test/build.gradle.kts b/test/build.gradle.kts new file mode 100644 index 0000000..c3ba333 --- /dev/null +++ b/test/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + kotlin("jvm") version "1.7.0" + id("application") + id("java") +} + +group = "org.example" +version = "1.0-SNAPSHOT" + +repositories { + mavenCentral() +} + +java { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +application { + mainClass.set("derp.Main") +} + +dependencies { + implementation(kotlin("stdlib")) + implementation(kotlin("stdlib-jdk7")) + implementation(kotlin("stdlib-jdk8")) + + implementation(project(":lib")) + + implementation("commons-fileupload:commons-fileupload:1.4") + + implementation("org.glassfish.jersey.containers:jersey-container-grizzly2-http:3.0.5") + implementation("org.glassfish.jersey.containers:jersey-container-grizzly2-servlet:3.0.5") + runtimeOnly("org.glassfish.jersey.inject:jersey-hk2:3.0.5") + implementation("org.glassfish.hk2:hk2-api:3.0.3") + + implementation("com.fasterxml.jackson.core:jackson-databind:2.13.3") + implementation("com.fasterxml.jackson.core:jackson-annotations:2.13.3") + + testImplementation("org.junit.jupiter:junit-jupiter-api:5.9.0") + testRuntimeOnly("org.junit.jupiter:junit-jupiter-engine:5.9.0") +} + +tasks.getByName("test") { + useJUnitPlatform() +} \ No newline at end of file diff --git a/test/src/main/java/derp/Controller.java b/test/src/main/java/derp/Controller.java new file mode 100644 index 0000000..d7146a0 --- /dev/null +++ b/test/src/main/java/derp/Controller.java @@ -0,0 +1,35 @@ +package derp; + +import jakarta.ws.rs.*; +import jakarta.ws.rs.core.MediaType; + +@Path("/") +public class Controller { + + @GET + @Produces(MediaType.TEXT_PLAIN) + public String get() { + return "Goodbye cruel world."; + } + + @POST + @Path("model") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public void postModel(Model model) { + System.out.println("In the controller."); + System.out.println(model); + System.out.println(model.getFoo()); + System.out.println(model.getBar()); + System.out.println(model.getFizz()); + System.out.println(model.getBuzz()); + } + + @POST + @Path("enum") + @Consumes(MediaType.MULTIPART_FORM_DATA) + @Produces(MediaType.TEXT_PLAIN) + public void postEnum(EnumWithConstructor derp) { + System.out.println(derp); + } +} diff --git a/test/src/main/java/derp/EnumWithAnnotations.java b/test/src/main/java/derp/EnumWithAnnotations.java new file mode 100644 index 0000000..991f9e8 --- /dev/null +++ b/test/src/main/java/derp/EnumWithAnnotations.java @@ -0,0 +1,18 @@ +package derp; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public enum EnumWithAnnotations { + + @JsonProperty("foo") + SomeEnumValue1, + + @JsonProperty("bar") + SomeEnumValue2, + + @JsonProperty("fizz") + SomeEnumValue3, + + @JsonProperty("buzz") + SomeEnumValue4 +} diff --git a/test/src/main/java/derp/EnumWithConstructor.java b/test/src/main/java/derp/EnumWithConstructor.java new file mode 100644 index 0000000..36618fb --- /dev/null +++ b/test/src/main/java/derp/EnumWithConstructor.java @@ -0,0 +1,22 @@ +package derp; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public enum EnumWithConstructor { + SomeValue("some value"); + + public final String value; + + EnumWithConstructor(String value) { + this.value = value; + } + + @JsonCreator + public static EnumWithConstructor fromString(String value) { + for (EnumWithConstructor val : values()) + if (val.value.equals(value)) + return val; + + throw new IllegalArgumentException("Unrecognized enum value: " + value); + } +} diff --git a/test/src/main/java/derp/ErrorMapper.kt b/test/src/main/java/derp/ErrorMapper.kt new file mode 100644 index 0000000..2a755a6 --- /dev/null +++ b/test/src/main/java/derp/ErrorMapper.kt @@ -0,0 +1,19 @@ +package derp + +import jakarta.ws.rs.BadRequestException +import jakarta.ws.rs.core.Response +import jakarta.ws.rs.ext.ExceptionMapper + +class ErrorMapper : ExceptionMapper { + + override fun toResponse(exception: Throwable): Response { + val code = when (exception) { + is BadRequestException -> 400 + else -> 500 + } + + exception.printStackTrace() + + return Response.status(code).entity(exception.message).build() + } +} \ No newline at end of file diff --git a/test/src/main/java/derp/Main.java b/test/src/main/java/derp/Main.java new file mode 100644 index 0000000..fa6dff8 --- /dev/null +++ b/test/src/main/java/derp/Main.java @@ -0,0 +1,20 @@ +package derp; + +import jakarta.ws.rs.core.UriBuilder; +import org.glassfish.grizzly.http.server.HttpServer; +import org.glassfish.jersey.grizzly2.httpserver.GrizzlyHttpServerFactory; + +import java.io.IOException; + +public class Main { + + public static void main(String[] args) throws IOException { + HttpServer server = GrizzlyHttpServerFactory.createHttpServer( + UriBuilder.fromUri("//0.0.0.0").port(8080).build(), + new Resources() + ); + + server.start(); + } + +} diff --git a/test/src/main/java/derp/Model.java b/test/src/main/java/derp/Model.java new file mode 100644 index 0000000..0b9c3dc --- /dev/null +++ b/test/src/main/java/derp/Model.java @@ -0,0 +1,57 @@ +package derp; + +import com.fasterxml.jackson.annotation.JsonGetter; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; + +import java.io.File; + +public class Model { + @JsonProperty("foo") + private String foo; + + @JsonProperty("bar") + private File bar; + + @JsonProperty("fizz") + private EnumWithConstructor fizz; + + @JsonProperty("buzz") + private EnumWithAnnotations buzz; + + @JsonGetter("foo") + public String getFoo() { + return foo; + } + + @JsonSetter("foo") + public void setFoo(String foo) { + this.foo = foo; + } + + @JsonGetter("bar") + public File getBar() { + return bar; + } + + @JsonSetter("bar") + public void setBar(File bar) { + this.bar = bar; + } + + @JsonGetter("fizz") + public EnumWithConstructor getFizz() { + return fizz; + } + + @JsonSetter("fizz") + public void setFizz(EnumWithConstructor fizz) { + this.fizz = fizz; + } + + @JsonGetter("buzz") + public EnumWithAnnotations getBuzz() { return buzz; } + + @JsonSetter("buzz") + public void setBuzz(EnumWithAnnotations buzz) { this.buzz = buzz; } +} diff --git a/test/src/main/java/derp/Resources.java b/test/src/main/java/derp/Resources.java new file mode 100644 index 0000000..9ab0ba9 --- /dev/null +++ b/test/src/main/java/derp/Resources.java @@ -0,0 +1,21 @@ +package derp; + +import org.glassfish.jersey.server.ResourceConfig; +import org.veupathdb.lib.jaxrs.raml.multipart.MultipartApplicationEventListener; +import org.veupathdb.lib.jaxrs.raml.multipart.MultipartMessageBodyReader; + +public class Resources extends ResourceConfig { + public Resources() { + property("jersey.config.server.tracing.type", "ALL"); + property("jersey.config.server.tracing.threshold", "VERBOSE"); + + registerClasses( + MultipartApplicationEventListener.class, + + ErrorMapper.class, + + MultipartMessageBodyReader.class, + Controller.class + ); + } +}