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

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

The newest version!
package ws.osiris.core

import java.util.regex.Pattern

/**
 * A segment in an HTTP route; a segment represents the part of a route between two slashes, for example the route
 * `/foo/bar` contains two segments, one for `foo` and one for `bar`.
 */
sealed class Segment {

    companion object {

        private val variableSegmentPattern = Pattern.compile("\\{\\w+}")

        fun create(pathPart: String): Segment =
            if (variableSegmentPattern.matcher(pathPart).matches()) {
                VariableSegment(pathPart.substring(1..pathPart.length - 2))
            } else {
                FixedSegment(pathPart)
            }
    }
}

/**
 * A [Segment] that corresponds to a section of the path with a fixed name; e.g. the path `/foo/bar` contains
 * two fixed segments with [pathPart] `foo` and `bar` respectively.
 */
data class FixedSegment(val pathPart: String) : Segment()

/**
 * A [Segment] that corresponds to a section of the path that matches a path variable; e.g. the path `/{foo}`
 * contains one variable segment with a [variableName] of `foo`.
 */
data class VariableSegment(val variableName: String) : Segment()

/**
 * Represents a route in the API or a subsection of it; used when building a tree of [RouteNode] instances
 * from a list if [Route] instances defined in an [Api].
 *
 * For example, a [Route] with a path `/foo/bar/baz` can have a corresponding [SubRoute] with paths `foo`,
 * `bar` and `baz`. As the tree of route nodes is created, [SubRoute] instances will be created with paths
 * of `foo`, `bar` and finally just `foo`.
 */
internal class SubRoute private constructor(val route: Route, val segments: List) {

    constructor(route: Route) : this(route, segments(route.path))

    companion object {
        private fun segments(path: String): List =
            path.split('/').map { it.trim() }.filter { it.isNotEmpty() }.map { Segment.create(it) }
    }

    fun isEmpty(): Boolean = segments.isEmpty()

    fun head(): Segment = when {
        isEmpty() -> throw IllegalStateException("Cannot take the head of an empty sub-route")
        else -> segments[0]
    }

    fun tail(): SubRoute = when {
        isEmpty() -> throw IllegalStateException("Cannot take the tail of an empty sub-route")
        else -> SubRoute(route, segments.slice(1 until segments.size))
    }

    override fun toString(): String {
        return "SubRoute(route=$route, segments=$segments)"
    }
}

/**
 * A node in the tree of routes that make up an API.
 *
 * The routes in an API can be represented as a tree. The route at the base of the API `/` is represented by
 * the `RouteNode` at the root of the tree. Each node in the tree corresponds to one section of a route.
 *
 * For example, consider an API with the following routes:
 * ```
 * /foo
 * /foo/bar
 * /foo/baz
 * /qux
 * ```
 * It is represented by the following tree:
 * ```
 * /
 *  |- foo
 *  |   |- bar
 *  |   `- baz
 *  `- qux
 * ```
 */
sealed class RouteNode(

    /** The name of the node; For a fixed node this is the path part, for a variable node it's the variable name. */
    val name: String,

    /** Handlers (and their associated [Auth]) keyed by the HTTP method they handle */
    val handlers: Map, Auth?>>,

    /** The fixed node children of this node, keyed by their path part. */
    val fixedChildren: Map>,

    /** The variable node that is a child of this node; has a variable path part, e.g. `{foo}`. */
    val variableChild: VariableRouteNode?
) {

    companion object {

        /**
         * Creates a tree of `RouteNode` instances representing the routes in the API.
         *
         * @return the root node
         */
        fun  create(api: Api): RouteNode =
            node(FixedSegment(""), api.routes.map { SubRoute(it) })

        /**
         * Creates a tree of `RouteNode` instances representing the routes.
         *
         * For testing.
         *
         * @return the root node
         */
        internal fun  create(vararg routes: Route): RouteNode =
            node(FixedSegment(""), routes.map { SubRoute(it) })

        /**
         * Recursively builds a tree of [RouteNode] instances.
         *
         * [segment] represents the path segment corresponding to the current node.
         *
         * [routes] represents the route parts below the current node. For example, if an API contains these
         * endpoints
         *
         *     /foo/bar/baz
         *     /foo/bar/qux
         *
         * when this function is invoked for the `/foo` node then [routes] will contain [SubRoute] instances
         * for `/bar/baz` and `/bar/qux`.
         */
        private fun  node(segment: Segment, routes: List>): RouteNode {
            // empty routes matches this node. there can be 1 per HTTP method
            val (emptyRoutes, nonEmptyRoutes) = routes.partition { it.isEmpty() }
            val isStaticEndpoint = emptyRoutes.any { it.route is StaticRoute<*> }
            val handlersByMethod = if (isStaticEndpoint) mapOf() else createHandlers(emptyRoutes)
            // non-empty routes form the child nodes
            val (fixedRoutes, variableRoutes) = nonEmptyRoutes.partition { it.head() is FixedSegment }

            // group fixed routes by the first segment - there is one node per unique segment name
            val fixedRoutesBySegment = fixedRoutes.groupBy { it.head() as FixedSegment }
            val fixedChildren = fixedRoutesBySegment
                .mapValues { (_, routes) -> routes.map { it.tail() } }
                .mapValues { (segment, tailRoutes) -> node(segment, tailRoutes) }
                .mapKeys { (segment, _) -> segment.pathPart }

            // The variable segment of the variable routes, e.g. bar in /foo/{bar}/baz
            val variableSegments = variableRoutes.map { it.head() }.toSet()
            // API gateway only allows a single variable name for a variable segment
            // so there can't be two routes passing through the same variable node using a different variable name
            // /foo/{bar} and /foo/{bar}/baz is OK
            // /foo/{bar} and /foo/{qux}/baz is not as /foo/{bar} and /foo/{qux} are the same route but with
            // a different variable name in the same location
            if (variableSegments.size > 1) {
                throw IllegalArgumentException("Routes found with clashing variable names: $variableRoutes")
            }
            val variableSegment = variableSegments.firstOrNull()
            val variableChild = variableSegment?.let {
                node(it, variableRoutes.map { subRoute -> subRoute.tail() }) as VariableRouteNode
            }
            return if (isStaticEndpoint) {
                if (variableSegment != null) {
                    // TODO a static endpoint shouldn't have any variables anywhere but that's harder to enforce
                    throw IllegalArgumentException("A static endpoint must not have any variable children")
                }
                if (emptyRoutes.size > 1) {
                    throw IllegalArgumentException("A static endpoint must be the only method for its path")
                }
                if (segment !is FixedSegment) {
                    throw IllegalArgumentException("A static endpoint must not end with a variable path part")
                }
                val staticRoute = emptyRoutes[0].route as StaticRoute<*>
                StaticRouteNode(segment.pathPart, fixedChildren, staticRoute.auth, staticRoute.indexFile)
            } else {
                when (segment) {
                    is FixedSegment -> FixedRouteNode(
                        segment.pathPart,
                        handlersByMethod,
                        fixedChildren,
                        variableChild
                    )
                    is VariableSegment -> VariableRouteNode(
                        segment.variableName,
                        handlersByMethod,
                        fixedChildren,
                        variableChild
                    )
                }
            }
        }

        private fun  createHandlers(
            emptyRoutes: List>
        ): Map, Auth>> {
            val routesByMethod = emptyRoutes.groupBy { ((it.route) as LambdaRoute).method }
            return routesByMethod
                .mapValues { (_, routes) -> checkSingleMethod(routes) }
                .mapValues { (_, route) -> createHandler(route) }
        }

        // These SubRoutes are guaranteed to contain LambdaRoutes but the type system doesn't know
        private fun  createHandler(subRoute: SubRoute): Pair, Auth> =
            Pair((subRoute.route as LambdaRoute).handler, subRoute.route.auth)

        private fun  checkSingleMethod(routes: List>): SubRoute =
            if (routes.size == 1) {
                routes[0]
            } else {
                val routeStrs = routes.map { "${(it.route as LambdaRoute).method.name} ${it.route.path}" }.toSet()
                throw IllegalArgumentException("Multiple routes with the same HTTP method $routeStrs")
            }
    }
}

/**
 * Node representing an endpoint ending with a fixed segment, e.g. `/foo/bar`.
 */
class FixedRouteNode(
    name: String,
    handlers: Map, Auth>>,
    fixedChildren: Map>,
    variableChild: VariableRouteNode?
) : RouteNode(name, handlers, fixedChildren, variableChild)

/**
 * Node representing an endpoint ending with a variable segment, e.g. `/foo/{bar}`.
 */
class VariableRouteNode(
    name: String,
    handlers: Map, Auth>>,
    fixedChildren: Map>,
    variableChild: VariableRouteNode?
) : RouteNode(name, handlers, fixedChildren, variableChild)

/**
 * Node representing an endpoint serving static files from S3.
 */
class StaticRouteNode(
    name: String,
    fixedChildren: Map>,
    val auth: Auth,
    val indexFile: String?
) : RouteNode(name, mapOf(), fixedChildren, null)

/**
 * Returns a pretty-printed string showing the node and its children in a tree structure.
 */
fun RouteNode<*>.prettyPrint(): String {
    fun RouteNode<*>.prettyPrint(builder: StringBuilder, indent: String) {
        builder.append(indent).append("/")
        val pathPart = when (this) {
            is FixedRouteNode<*> -> name
            is StaticRouteNode<*> -> name
            is VariableRouteNode<*> -> "{$name}"
        }
        builder.append(pathPart)
        builder.append(" ")
        if (handlers.isNotEmpty()) builder.append(handlers.keys.map { it.name })
        for (fixedChild in fixedChildren.values) {
            builder.append("\n")
            fixedChild.prettyPrint(builder, "  $indent")
        }
        variableChild?.apply {
            builder.append("\n")
            prettyPrint(builder, "  $indent")
        }
    }

    val stringBuilder = StringBuilder()
    prettyPrint(stringBuilder, "")
    return stringBuilder.toString()
}

internal class RequestPath private constructor(val path: String, val segments: List) {

    constructor(path: String) : this(path, split(path))

    fun isEmpty(): Boolean = segments.isEmpty()

    fun head(): String = when {
        isEmpty() -> throw IllegalStateException("Cannot take the head of an empty RequestPath")
        else -> segments[0]
    }

    fun tail(): RequestPath = when {
        isEmpty() -> throw IllegalStateException("Cannot take the tail of an empty RequestPath")
        else -> RequestPath(path, segments.slice(1 until segments.size))
    }

    companion object {
        fun split(path: String): List = path.split("/").map { it.trim() }.filter { !it.isEmpty() }
    }
}

data class RouteMatch(val handler: RequestHandler, val vars: Map)

fun  RouteNode.match(method: HttpMethod, path: String): RouteMatch? {

    fun  RouteNode.match(reqPath: RequestPath, vars: Map): RouteMatch? {
        if (reqPath.isEmpty()) return handlers[method]?.let { RouteMatch(it.first, vars) }
        val head = reqPath.head()
        val tail = reqPath.tail()
        val fixedMatch = fixedChildren[head]?.match(tail, vars)
        return fixedMatch ?: variableChild?.match(tail, vars + (variableChild.name to head))
    }
    return match(RequestPath(path), mapOf())
}





© 2015 - 2024 Weber Informatics LLC | Privacy Policy