[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 indata
;meta
is a list ofdata's
fields description: their name, type, and table location with the same indexes. Someta[0]
values are equaldata[0][0]
value, anddata[1][0]
value, anddata[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 inResult
stores the values we need and in our casedata
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 themeta
value to find thedata
item by themeta
index. Remember that themeta
anddata
values share identical indexes. Store the data item infieldValue
. - Find the field in our class we want to fill using
meta's
field name and store that class field name infield
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