All Downloads are FREE. Search and download functionalities are using the official Maven repository.

ws.osiris.core.Http.kt Maven / Gradle / Ivy

The newest version!
package ws.osiris.core

import ws.osiris.core.ContentType.Companion.parse
import java.net.URLDecoder
import java.nio.charset.Charset
import java.util.Locale
import kotlin.reflect.KClass

/**
 * A set of HTTP parameters provided as part of request; can represent headers, path parameters or
 * query string parameters.
 *
 * Parameter lookup is case-insensitive in accordance with the HTTP spec. For example, if a
 * request contains the header "Content-Type" then the value will be returned when looked up as follows:
 *
 *     val contentType = req.headers["content-type"]
 *
 * This class does not support repeated values in query strings as API Gateway doesn't support them.
 * For example, a query string of `foo=123&foo=456` will only contain one value for `foo`. It is
 * undefined which one.
 */
class Params(params: Map?) {

    constructor() : this(mapOf())

    val params: Map = params ?: mapOf()

    private val lookupParams = this.params.mapKeys { (key, _) -> key.lowercase(Locale.ENGLISH) }

    /** Returns the named parameter or throws `IllegalArgumentException` if there is no parameter with the name. */
    operator fun get(name: String): String = optional(name) ?: throw IllegalArgumentException("No value named '$name'")

    /**
     * Returns copy of these parameters with the value added.
     *
     * The value overwrites an existing parameter with the same name.
     */
    operator fun plus(nameValue: Pair) = Params(params + nameValue)

    /** Returns copy of these parameters with the named parameter removed. */
    operator fun minus(name: String) = Params(params - name)

    /** Returns the named parameter. */
    fun optional(name: String): String? = lookupParams[name.lowercase(Locale.ENGLISH)]

    companion object {

        /** Creates a set of parameters by parsing an HTTP query string. */
        fun fromQueryString(queryString: String?): Params = when {
            queryString == null || queryString.trim().isEmpty() -> Params(mapOf())
            else -> Params(URLDecoder.decode(queryString, "UTF-8").split("&").map { splitVar(it) }.toMap())
        }

        private fun splitVar(nameAndValue: String): Pair {
            return when (val index = nameAndValue.indexOf('=')) {
                -1 -> Pair(nameAndValue, "")
                else -> Pair(nameAndValue.substring(0, index), nameAndValue.substring(index + 1, nameAndValue.length))
            }
        }
    }
}

// TODO add field bodyObj: Any? to allow filters to pre-process the body?
// e.g. could look at the content type of the request body and parse the body into an object. the handler would handle
// the object and the parsing could be isolated to filters, one for each content type. new content types could be
// supported by adding a new filter
/**
 * Contains the details of an HTTP request received by the API.
 */
data class Request(
    val method: HttpMethod,
    val path: String,
    val headers: Params,
    val queryParams: Params,
    val pathParams: Params,
    val context: Params,
    val body: Any? = null,
    val attributes: Map = mapOf(),
    val defaultResponseHeaders: Map = mapOf()
) {

    internal val requestPath: RequestPath = RequestPath(path)

    /** Returns the body or throws `IllegalArgumentException` if it is null. */
    @Deprecated("Use body()", replaceWith = ReplaceWith("body()"))
    fun requireBody(): Any = body ?: throw IllegalArgumentException("Request body is required")

    /** Returns the body or throws `IllegalArgumentException` if it is null or not of the expected type. */
    @Suppress("UNCHECKED_CAST")
    @Deprecated("Use body()", replaceWith = ReplaceWith("body()"))
    fun  requireBody(expectedType: KClass): T = when {
        body == null -> throw IllegalArgumentException("Request body is required")
        !expectedType.java.isInstance(body) -> throw IllegalArgumentException("Request body is not of the expected type")
        else -> body as T
    }

    /** Returns the body or throws `IllegalArgumentException` if it is null or not of the expected type. */
    @Suppress("UNCHECKED_CAST")
    inline fun  body(): T = when {
        body == null -> throw IllegalArgumentException("Request body is required")
        !T::class.java.isInstance(body) -> throw IllegalArgumentException("Request body is not of the expected type")
        else -> body as T
    }

    /** Returns the body as a byte array or throws `IllegalArgumentException` if there is no body or it isn't binary. */
    fun requireBinaryBody(): ByteArray = when (body) {
        null -> throw IllegalArgumentException("Request body is required")
        is ByteArray -> body
        else -> throw IllegalArgumentException("Request body is not binary")
    }

    /**
     * Returns a builder for building a response.
     *
     * This is used to customise the headers or the status of the response.
     */
    fun responseBuilder(): ResponseBuilder =
        ResponseBuilder(defaultResponseHeaders.toMutableMap())

    /**
     * Returns the named attribute with the specified type.
     *
     * @throws IllegalStateException if there is no attribute with the specified name of if an attribute is
     * found with the wrong type
     */
    inline fun  attribute(name: String): T {
        val attribute = attributes[name] ?: throw IllegalStateException("No attribute found with name '$name'")
        if (attribute !is T) {
            throw IllegalStateException("Attribute '$name' does not have expected type. " +
                "Expected ${T::class.java.name}, found ${attribute.javaClass.name}")
        }
        return attribute
    }

    /**
     * Returns a copy of this request with the value added to its attributes, keyed by the name.
     */
    fun withAttribute(name: String, value: Any): Request = copy(attributes = attributes + (name to value))
}

/**
 * Standard HTTP header names.
 */
object HttpHeaders {
    const val ACCEPT = "Accept"
    const val AUTHORIZATION = "Authorization"
    const val CONTENT_TYPE = "Content-Type"
    const val CONTENT_LENGTH = "Content-Length"
    const val LOCATION = "Location"
}

/**
 * Standard MIME types.
 */
object MimeTypes {
    const val APPLICATION_JSON = "application/json"
    const val APPLICATION_XML = "application/xml"
    const val APPLICATION_XHTML = "application/xhtml+xml"
    const val TEXT_HTML = "text/html"
    const val TEXT_PLAIN = "text/plain"
}

/** The default content type in API Gateway; everything is assumed to return JSON unless it states otherwise. */
val JSON_CONTENT_TYPE = ContentType(MimeTypes.APPLICATION_JSON)

// It might have been nicer to make this a sealed type with subtypes for text and binary content types.
// Charset is only relevant for text types and boundary is only relevant for multipart form data.

/**
 * Represents the data in a `Content-Type` header; includes the MIME type and an optional charset.
 *
 * The [parse] function parses a `Content-Type` header and creates a [ContentType] instance.
 */
data class ContentType(
    /** The MIME type of the content. */
    val mimeType: String,
    /** The charset of the content. */
    val charset: Charset?,
    /** The boundary between fields in `multipart/form-data`. */
    val boundary: String? = null
) {

    /** The string representation of this content type used in a `Content-Type` header. */
    val header: String

    init {
        if (this.mimeType.isBlank()) throw IllegalArgumentException("MIME type cannot be blank")
        header = if (charset == null && boundary == null) {
            mimeType.trim()
        } else if (charset != null) {
            "${mimeType.trim()}; charset=${charset.name()}"
        } else {
            "${mimeType.trim()}; boundary=$boundary"
        }
    }

    /** Creates an instance with the specified MIME type and no charset or boundary. */
    constructor(mimeType: String) : this(mimeType, null)

    companion object {

        private val REGEX = Regex("""\s*(?\S+?)\s*(;\s*charset=(?\S+)\s*)?""", RegexOption.IGNORE_CASE)
        private val MULTIPART_FORM_REGEX = Regex("""\s*multipart/form-data\s*;\s*boundary=(?\S{1,70})\s*""", RegexOption.IGNORE_CASE)

        /**
         * Parses a `Content-Type` header into a [ContentType] instance.
         */
        fun parse(header: String): ContentType {
            val multipartResult = MULTIPART_FORM_REGEX.matchEntire(header)
            if (multipartResult != null) {
                val boundary = multipartResult.groups["boundary"]?.value
                return ContentType("multipart/form-data", null, boundary)
            }
            val matchResult = REGEX.matchEntire(header) ?: throw IllegalArgumentException("Invalid Content-Type")
            val mimeType = matchResult.groups["type"]?.value!!
            val charset = matchResult.groups["charset"]?.let { Charset.forName(it.value) }
            return ContentType(mimeType, charset)
        }
    }
}

/**
 * Builder for building custom responses.
 *
 * Response builders are used in cases where the response headers or status need to be changed from the defaults.
 * This happens when the response is a success but the status is not 200 (OK). For example, 201 (created) or
 * 202 (accepted).
 *
 * It is also necessary to use a builder to change any of the headers, for example if the content type
 * is not the default.
 */
class ResponseBuilder internal constructor(val headers: MutableMap) {

    private var status: Int = 200

    /** Sets the value of the named header and returns this builder. */
    fun header(name: String, value: String): ResponseBuilder {
        headers[name] = value
        return this
    }

    /** Sets the status code of the response and returns this builder. */
    fun status(status: Int): ResponseBuilder {
        this.status = status
        return this
    }

    /** Builds a response from the data in this builder. */
    fun build(body: Any? = null): Response = Response(status, Headers(headers), body)
}

/**
 * The details of the HTTP response returned from the code handling a request.
 *
 * It is only necessary to return a `Response` when the headers or status need to be customised.
 * In many cases it is sufficient to return a value that is serialised into the response body
 * and has a status of 200 (OK).
 *
 * Responses should be created using the builder returned by [Request.responseBuilder]. The builder
 * will be initialised with the default response headers so the user only needs to specify the
 * headers whose values they wish to change.
 */
data class Response internal constructor(val status: Int, val headers: Headers, val body: Any?) {

    /**
     * Returns a copy of these headers with a new header added.
     *
     * If this object already contains the header it will be replaced by the new value.
     */
    fun withHeader(header: String, value: String): Response = copy(headers = headers + (header to value))

    /**
     * Returns a copy of these headers with new headers added.
     *
     * If this object already contains the headers they will be replaced by the new values.
     */
    fun withHeaders(vararg headerValue: Pair): Response = copy(headers = headers + headerValue.toMap())

    /**
     * Returns a copy of these headers with new headers added.
     *
     * If this object already contains the headers they will be replaced by the new values.
     */
    fun withHeaders(headersMap: Map): Response = copy(headers = headers + headersMap)

    /**
     * Returns a copy of these headers with new headers added.
     *
     * If this object already contains the headers they will be replaced by the new values.
     */
    fun withHeaders(headers: Headers): Response = copy(headers = this.headers + headers)

    companion object {

        /**
         * Returns an error response with content type `text/plain` and the specified [status] and
         * with [message] as the request body.
         */
        internal fun error(status: Int, message: String?): Response {
            val headers = mapOf(HttpHeaders.CONTENT_TYPE to MimeTypes.TEXT_PLAIN)
            return Response(status, Headers(headers), message)
        }
    }
}

/** A map of HTTP headers that looks up values in a case-insensitive fashion (in accordance with the HTTP spec). */
data class Headers(val headerMap: Map = mapOf()) {

    constructor(vararg headers: Pair) : this(headers.toMap())

    private val lookupMap = headerMap.mapKeys { (key, _) -> key.lowercase(Locale.ENGLISH) }

    /**
     * Returns the value for the specified [header]; lookup is case-insensitive in accordance with the HTTP spec.
     */
    operator fun get(header: String): String? = lookupMap[header.lowercase(Locale.ENGLISH)]

    /**
     * Returns a copy of these headers with a new header added.
     *
     * If this object already contains the header it will be replaced by the new value.
     */
    operator fun plus(headerValue: Pair): Headers = withHeader(headerValue.first, headerValue.second)

    /**
     * Returns a copy of these headers with new headers added.
     *
     * If this object already contains the headers they will be replaced by the new values.
     */
    operator fun plus(other: Headers): Headers = Headers(headerMap + other.headerMap)

    /**
     * Returns a copy of these headers with new headers added.
     *
     * If this object already contains the headers they will be replaced by the new values.
     */
    operator fun plus(other: Map): Headers = Headers(headerMap + other)

    /**
     * Returns a copy of these headers with a new header added.
     *
     * If this object already contains the header it will be replaced by the new value.
     */
    fun withHeader(header: String, value: String): Headers = Headers(headerMap + (header to value))

    /**
     * Returns a copy of these headers with new headers added.
     *
     * If this object already contains the headers they will be replaced by the new values.
     */
    fun withHeaders(vararg headers: Pair): Headers = Headers(headerMap + headers.toMap())
}

enum class HttpMethod {
    GET,
    POST,
    PUT,
    UPDATE,
    OPTIONS,
    PATCH,
    DELETE
}

/**
 * Creates a [Params] instance representing the request context; only used in testing.
 *
 * When an Osiris application is deployed on AWS then the request context is filled in by API Gateway. In some
 * cases the handler code uses the context, for example to get the name of the API Gateway stage. When the
 * application is running in a local server the context information needed by the handler code must still be
 * provided.
 *
 * This interface provides a way for the user to plug-in something to generate valid context information when
 * the code is running on a regular HTTP server.
 *
 * There are two simple implementations provided
 *
 * This is only needed in testing, but needs to be in the `core` module so it can be used in the core tests
 * and in the main HTTP server code in `local-server`.
 */
interface RequestContextFactory {

    fun createContext(
        httpMethod: HttpMethod,
        path: String,
        headers: Params,
        queryParams: Params,
        pathParams: Params,
        body: Any?
    ): Params

    companion object {

        /** Returns a factory that returns an empty context; used by default if no other factory is provided.  */
        fun empty(): RequestContextFactory = EmptyRequestContextFactory()

        /** Returns a factory that returns the same context every time, built from `values`. */
        fun fixed(vararg values: Pair): RequestContextFactory = FixedRequestContextFactory(values.toMap())
    }
}

private class EmptyRequestContextFactory : RequestContextFactory {

    private val context: Params = Params()

    override fun createContext(
        httpMethod: HttpMethod,
        path: String,
        headers: Params,
        queryParams: Params,
        pathParams: Params,
        body: Any?
    ): Params = context

}

private class FixedRequestContextFactory(values: Map) : RequestContextFactory {

    private val context = Params(values)

    override fun createContext(
        httpMethod: HttpMethod,
        path: String,
        headers: Params,
        queryParams: Params,
        pathParams: Params,
        body: Any?
    ): Params = context
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy