[Work With UMA Requests on Android]

UMA (Unified Model Access) is a software development approach where different data sources are accessed and managed through a unified interface or system. This concept is particularly useful in complex applications where data may come from various sources, such as databases, APIs, or different services, and needs to be accessed in a consistent way.

For usual endpoints we use a unique path and handle the response as follows:

@GET("/api/user/") 
suspend fun getUser(): Response<UserResponse>

With a UMA request we only have one path:

@POST("/api/uma/select/")
suspend fun makeSelectRequest(@Body payload: RequestBody): FullResult

To make the UMA request via retrofit we have to prepare the following:

  • The request should always be @POST and the path should always be  "/api/v1/uma/select/"
  • If for usual calls we had a new method with new models for each request, in UMA calls we have only one method with the same models for getting data. In our case, it will be suspend fun makeSelectRequest(@Body payload: RequestBody): FullResult.
  • We should pass the answer structure we expect into the body field. FullResult will come to the model structure we passed into RequestBody.

The request

Using the architecture with data (DataSource inside), domain (Interactor inside), and presentation layers, we have to build the request in the data layer and pass it to our makeSelectRequest() in a separate interface used for UMA requests to make the UMA call. First, we call the payload builder builtUsersRequest(), map it to the RequestBody, and then pass it into our makeSelectRequest() function as body param. 

suspend fun getUsersList(date: String, users_id: Int): List<UserResponse> {
...
val payload = builtUsersRequest(    
    date = date,    
    users_id = users_id    
    )
val response = umaApi.makeSelectRequest(payload.toRequestBody())
val usersResponseList: List<UserResponse> = response.result?.toDecoded() ?: 
emptyList()
...

umaApi is the instance of the interface where you store your makeSelectRequest.

For every request, we have our own datasource as the request builder function. In the example above, you can see builtUsersRequest(). We have class Payload which has necessary fields. It has all the field types we can face in the project. You can build this class through learning the documentation or navigating in the Layout Inspector on the Frontend (if you have one). In the request, we write and pass through the table to the necessary field we want to receive in response. As a result, our request model builds the response model.

Payload builder example:

 
fun builtUsersRequest(date: String, users_id: Int): Payload {
    return Payload(
        params = Params(
            end_date = date,
            users_id = users_id
        ),
        request = Request(
            table = Table(name = "users_table", alias = "us"),
            columns = listOf(
                Columns(name = "tr.id"),
                Columns(name = "tr.name"),
                Columns(name = "tr.avatar"),
            ),
            filters = listOf(
                Filters(field = "us.id", op = "eq", value = ":users_id"),

            )
            sorts = listOf(Sorts(field = "tr.id", direction = "desc"))
        )
    )
}

Payload is a class based on the UMA body requirements. In this case:

data class Payload(
   val params: Params? = null,
   val request: Request
)


data class Params(
   val date: String? = null,
   val users_id: Int? = null,
)

data class Request(
   val columns: List<Columns>,
   val table: Table,
   val filters: List<Filter>? = null,
   val sorts: List<Sorts>? = null,
   val limit: Int? = null,
   val offset: Int? = null,
)

data class Filter(
   val field: String? = null,
   val op: String? = null,
   val value: String? = null,
   val operator: String? = null,
) 
...
//such classes depends only on provided to you structure and on your response you're planning to get  

The response

All UMA requests have the same response structure. This response is a result object and it should have 3 files: meta, data and total.

Response example:

{
  "result": {
    "data": [
      [
        11,
        "George Russell",
        null,
      ],
      [
        55,
        "Carlos Sainz",
        null
      ]
    ],
    "meta": [
      {
        "field_name": "users_id",
        "schema": [
          "user_main",
          "users_id"
        ],
        "type": "integer"
      },
      {
        "field_name": "users_name",
        "schema": [
          "user_main",
          "users_name"
        ],
        "type": "string"
      },
      {
        "field_name": "users_avatar",
        "schema": [
          "user_main",
          "users_avatar"
        ],
        "type": "string"
      }
    ],
    "total": 2
  }
}   

We can handle it with a class where we store the result object with data, meta, and total.

 
data class FullResult(
   val result: Result?
)


data class Result(
   val data: List<List<Any>>?,
   val meta: List<Meta>?,
   val total: Int?
) 
  • data is a list of lists of your response data;
  • total is the amount of elements in data;
  • meta is a list of data's fields description: their name, type, and table location with the same indexes. So meta[0] values are equal data[0][0] value, and data[1][0] value, and data[2][0] value etc.
data class Meta(
    val field_name: String,
    val schema: List<String>,
    val type: String
)  

Remember our call from dataSource:

val payload = builtUsersRequest(
    date = date,
    users_id = users_id
    )
val response = umaApi.makeSelectRequest(payload.toRequestBody())
val usersResponseList: List<UserResponse> = response.result?.toDecoded() ?: emptyList()

Now, we know how to build the payload and since the request requires RequestBody as a body param we need to convert our payload object into RequestBody. Here I use the extension .toRequestBody().

.toRequestBody() extension implementation:

fun Any.toRequestBody(): RequestBody {
   val result = serializeObjectWithoutNullFields(this)
   return result.toRequestBody("application/json; charset=utf-8".toMediaType())
}

fun serializeObjectWithoutNullFields(obj: Any): String {
   val gson = GsonBuilder()
       .create()

   val jsonObject = gson.toJsonTree(obj).asJsonObject
   jsonObject.entrySet().removeIf { it.value.isJsonNull }

   return gson.toJson(jsonObject)
}   

Now, we made the request and got the response into our response field. If you remember, as a response, we wait for FullResult, which stores data in a very specific way. So the next thing we're going to do is to prepare a class(UserResponse) with values we will be able to use and then decode that response into our class via the .toDecoded() extension.

Are you intrigued? I hope so, because this is the best part :D

To create the response class you will be able to use as needed, you have to remember the following things:

  • The response classes should have filed names identical to the meta field_name values
  • The response classes should not be data classes because we need  empty constructors for our decoder.
class UserResponse {
    var users_id: Int?
    var users_name: String?
    var users_avatar: String?

    constructor() : this(0, "", "") {}
    constructor(
        users_id: Int?,
        users_name: String?,
        users_avatar: String?
    ) {
        this.users_id = users_id
        this.users_name = users_name
        this.users_avatar = users_avatar
    }
}

We map the data and meta data into models we need, via a decoder.

The .toDecoded() extension:

inline fun <reified T> Result.toDecoded(): List<T> {
    val list = arrayListOf<T>()

    if (data != null) {
        for (i in data) {
            val templateResponse = 
            T::class.constructors.find { it.parameters.isEmpty() }?.call()

            meta?.forEachIndexed { index, meta ->
                val fieldValue = i[index]
                val field: Field = T::class.java.getDeclaredField(meta.field_name)
                field.isAccessible = true
                field.set(templateResponse, fieldValue)
            }
            templateResponse?.let { list.add(it) }
        }
    }
    return list  

This decoder function is an extension for the Result class and a class stored in FullResult.

Let's see what's going on:

  • Since the data field in Result stores the values we need and in our case data is always a list, our extension will return a list of elements we declare in the call place:
val usersResponseList: List<UserResponse> = response.result?.toDecoded() ?:emptyList()  

You should either set the return type with a list of elements you expect the decoder to return to you, or specify the type in the following way:

val usersResponseList = response.result?.toDecoded<UserResponse>() ?:emptyList()  
  • In the decoder, create a list which you will return with a generic type.
  • Create the value templateResponse and find an empty contractor in your generic to use and initialize the object further.
  • Go through the data values and, within each, navigate through the meta value to find the data item by the meta index. Remember that the meta and data values share identical indexes. Store the data item in fieldValue.
  • Find the field in our class we want to fill using meta's field name and store that class field name in field value.
  • Allow field modification:
 field.isAccessible = true
  • Use found field to set the necessary value by passing the object whose filed should be modified and the new value for the field of object being modified.
  • After you've gone through all meta values (through the single data value), add a filled templateResponse item to the result list.
  • Go through the next data value.

After you've gone through all the data values, the .toDecoded() extension will return a list of filled data, which is a list of UserResponse in this case. 

That's it! We can now make requests and handle simple responses using UMA. Of course, in more complex cases, you may need to extend the decoder to accommodate more intricate structures. However, I've provided the foundation needed to start working with UMA and manage basic scenarios effectively.

The UMA approach offers several advantages, such as greater flexibility and a reduced burden on the backend when dealing with complex systems, as front-end developers query exactly what they need to receive. However, there are some drawbacks to consider. For instance, front-end developers need to have a deeper understanding of SQL compared to when using traditional endpoints with tools like Retrofit. Additionally, the amount of information and possibilities with UMA can be overwhelming, requiring front-end developers to collaborate more closely with the back-end team to ensure proper implementation and efficient querying.

Resources for learning more about the UMA approach:

https://en.wikipedia.org/wiki/Data_access_object

https://valentin.willscher.de/posts/sql-api/

https://pypi.org/project/sqlalchemy-filters/0.3.0/