RouteBuilder.kt

/*
 * Copyright 2024 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

@file:OptIn(ExperimentalSerializationApi::class)

package androidx.navigation.serialization

import androidx.navigation.CollectionNavType
import androidx.navigation.NavType
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.KSerializer

/**
 * Builds navigation routes from a destination class or instance.
 */
internal sealed class RouteBuilder<T> private constructor() {
    /**
     * Builds a route pattern
     *
     * @param serializer The serializer for destination type T (class, object etc.) that you
     * need to build the route for.
     */
     class Pattern<T>(
        serializer: KSerializer<T>,
        typeMap: Map<String, NavType<Any?>>
     ) : RouteBuilder<T>() {

        private val builder = Builder(serializer, typeMap)

        fun addArg(elementIndex: Int) {
            builder.apply(elementIndex) { name, _, paramType ->
                when (paramType) {
                    ParamType.PATH -> addPath("{$name}")
                    ParamType.QUERY -> addQuery(name, "{$name}")
                }
            }
        }

        fun build(): String = builder.build()
    }

    /**
     * Builds a route filled with argument values
     *
     * @param serializer The serializer for destination instance that you
     * need to build the route for.
     *
     * @param typeMap A map of argument name to the NavArgument of all serializable fields
     * in this destination instance
     */
    class Filled<T>(
        serializer: KSerializer<T>,
        private val typeMap: Map<String, NavType<Any?>>
    ) : RouteBuilder<T>() {

        private val builder = Builder(serializer, typeMap)
        private var elementIndex = -1

        /**
         * Set index of the argument that is currently getting encoded
         */
        fun setElementIndex(idx: Int) {
            elementIndex = idx
        }

        /**
         * Adds argument value to the url
         */
        fun addArg(value: Any?) {
            require(!(value == null || value == "null")) {
                "Expected non-null value but got $value"
            }
            builder.apply(elementIndex) { name, type, paramType ->
                val parsedValue = if (type is CollectionNavType) {
                    type.serializeAsValues(value)
                } else {
                    listOf(type.serializeAsValue(value))
                }
                when (paramType) {
                    ParamType.PATH -> {
                        // path arguments should be a single string value of primitive types
                        require(parsedValue.size == 1) {
                            "Expected one value for argument $name, found ${parsedValue.size}" +
                                "values instead."
                        }
                        addPath(parsedValue.first())
                    }
                    ParamType.QUERY -> parsedValue.forEach { addQuery(name, it) }
                }
            }
        }

        /**
         * Adds null value to the url
         */
        fun addNull(value: Any?) {
            require(value == null || value == "null") {
               "Expected null value but got $value"
            }
            builder.apply(elementIndex) { name, _, paramType ->
                when (paramType) {
                    ParamType.PATH -> addPath("null")
                    ParamType.QUERY -> addQuery(name, "null")
                }
            }
        }

        fun build(): String = builder.build()
    }

    enum class ParamType {
        PATH,
        QUERY
    }

    /**
     * Internal builder that generates the final url output
     */
    private class Builder<T>(
        val serializer: KSerializer<T>,
        val typeMap: Map<String, NavType<Any?>>
    ) {
        private val path = serializer.descriptor.serialName
        private var pathArgs = ""
        private var queryArgs = ""

        /**
         * Returns final route
         */
        fun build() = path + pathArgs + queryArgs

        /**
         * Append string to the route's (url) path
         */
        fun addPath(path: String) {
            pathArgs += "/$path"
        }

        /**
         * Append string to the route's (url) query parameter
         */
        fun addQuery(name: String, value: String) {
            val symbol = if (queryArgs.isEmpty()) "?" else "&"
            queryArgs += "$symbol$name=$value"
        }

        fun apply(
            index: Int,
            block: Builder<T>.(name: String, type: NavType<Any?>, paramType: ParamType) -> Unit
        ) {
            val descriptor = serializer.descriptor
            val elementName = descriptor.getElementName(index)
            val type = typeMap[elementName]
            checkNotNull(type) {
                "Cannot find NavType for argument $elementName. Please provide NavType through" +
                    "typeMap."
            }
            val paramType = computeParamType(index, type)
            this.block(elementName, type, paramType)
        }

        /**
         * Given the descriptor of [T], computes the [ParamType] of the element (argument)
         * at [index].
         *
         * Query args if either conditions met:
         * 1. has default value
         * 2. is of [CollectionNavType]
         */
        private fun computeParamType(index: Int, type: NavType<Any?>) =
            if (type is CollectionNavType || serializer.descriptor.isElementOptional(index)) {
                ParamType.QUERY
            } else {
                ParamType.PATH
            }
    }
}