Использование Gson и Retrofit 2 для десериализации сложных ответов API

Я использую Retrofit 2 и Gson, и у меня возникают проблемы с десериализацией ответов от моего API. Вот мой сценарий:

У меня есть объект модели с именем Employee который имеет три поля: id, name, age.

У меня есть API, который возвращает отдельный объект Employee как это:

{
    "status": "success",
    "code": 200,
    "data": {
        "id": "123",
        "id_to_name": {
            "123" : "John Doe"
        },
        "id_to_age": {
            "123" : 30
        }
    }
}

И список объектов Employee таких как:

{
    "status": "success",
    "code": 200,
    "data": [
        {
            "id": "123",
            "id_to_name": {
                "123" : "John Doe"
            },
            "id_to_age": {
                "123" : 30
            }
        },
        {
            "id": "456",
            "id_to_name": {
                "456" : "Jane Smith"
            },
            "id_to_age": {
                "456" : 35
            }
        },
    ]
}

Здесь нужно учесть три основных момента:

  1. Ответы API возвращаются в универсальной оболочке с важной частью внутри поля data.
  2. API возвращает объекты в формате, который не соответствует непосредственно полям модели (например, значение, взятое из id_to_age должно быть сопоставлено с полем age модели)
  3. Поле data в ответе API может быть единичным объектом или списком объектов.

Как реализовать десериализацию с помощью Gson, чтобы он элегантно обрабатывал эти три случая?

В идеале, я бы предпочел сделать это полностью с TypeAdapter или TypeAdapterFactory вместо того, чтобы платить штраф за производительность JsonDeserializer. В конечном итоге я хочу получить экземпляр Employee или List<Employee>, который удовлетворяет следующему интерфейсу:

public interface EmployeeService {

    @GET("/v1/employees/{employee_id}")
    Observable<Employee> getEmployee(@Path("employee_id") String employeeId);

    @GET("/v1/employees")
    Observable<List<Employee>> getEmployees();

}

Этот предыдущий вопрос, который я опубликовал, обсуждает мою первую попытку сделать это, но он не учитывает некоторые из упомянутых выше ошибок: используя Retrofit и RxJava, как мне десериализовать JSON, когда он не отображается непосредственно на объект модели?

Ответ 1

РЕДАКТИРОВАТЬ: Релевантное обновление: создание пользовательского конвертера factory Работает - ключом к предотвращению бесконечного цикла через ApiResponseConverterFactory является вызов Retrofit nextResponseBodyConverter, который позволяет вам указать factory, чтобы пропустить. Ключ это будет Converter.Factory для регистрации с помощью Retrofit, а не TypeAdapterFactory для Gson. Это было бы предпочтительнее, поскольку оно предотвращает двойную десериализацию ResponseBody (нет необходимости десериализовать тело, а затем повторно упаковать его снова как другой ответ).

Смотрите пример для примера реализации.

ОРИГИНАЛЬНЫЙ ОТВЕТ:

Подход ApiResponseAdapterFactory не работает, если вы не готовы переносить все свои служебные интерфейсы с помощью ApiResponse<T>. Однако есть еще один вариант: перехватчики OkHttp.

Здесь наша стратегия:

  • Для конкретной конфигурации модификации вы зарегистрируете перехватчик приложений, который перехватывает Response
  • Response#body() будет десериализован как ApiResponse, и мы вернем новый Response, где ResponseBody - это только то, что мы хотим.

Итак ApiResponse выглядит так:

public class ApiResponse {
  String status;
  int code;
  JsonObject data;
}

ApiResponseInterceptor:

public class ApiResponseInterceptor implements Interceptor {
  public static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
  public static final Gson GSON = new Gson();

  @Override
  public Response intercept(Chain chain) throws IOException {
    Request request = chain.request();
    Response response = chain.proceed(request);
    final ResponseBody body = response.body();
    ApiResponse apiResponse = GSON.fromJson(body.string(), ApiResponse.class);
    body.close();

    // TODO any logic regarding ApiResponse#status or #code you need to do 

    final Response.Builder newResponse = response.newBuilder()
        .body(ResponseBody.create(JSON, apiResponse.data.toString()));
    return newResponse.build();
  }
}

Настройте свой OkHttp и дооснащение:

OkHttpClient client = new OkHttpClient.Builder()
        .addInterceptor(new ApiResponseInterceptor())
        .build();
Retrofit retrofit = new Retrofit.Builder()
        .client(client)
        .build();

И Employee и EmployeeResponse должны следовать конструктору адаптера factory, который я написал в предыдущем вопросе. Теперь все поля ApiResponse должны потребляться перехватчиком, и каждый сделанный вами повторный доработок должен возвращать только интересующий вас контент JSON.

Ответ 2

Я бы предложил использовать JsonDeserializer, потому что в ответе не так много уровней вложенности, поэтому это не будет большой успех.

Классы будут выглядеть примерно так:

Интерфейс службы должен быть настроен на общий ответ:

interface EmployeeService {

    @GET("/v1/employees/{employee_id}")
    Observable<DataResponse<Employee>> getEmployee(@Path("employee_id") String employeeId);

    @GET("/v1/employees")
    Observable<DataResponse<List<Employee>>> getEmployees();

}

Это общий ответ:

class DataResponse<T> {

    @SerializedName("data") private T data;

    public T getData() {
        return data;
    }
}

Модель сотрудника:

class Employee {

    final String id;
    final String name;
    final int age;

    Employee(String id, String name, int age) {
        this.id = id;
        this.name = name;
        this.age = age;
    }

}

Дессеризатор сотрудников:

class EmployeeDeserializer implements JsonDeserializer<Employee> {

    @Override
    public Employee deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context)
            throws JsonParseException {

        JsonObject employeeObject = json.getAsJsonObject();
        String id = employeeObject.get("id").getAsString();
        String name = employeeObject.getAsJsonObject("id_to_name").entrySet().iterator().next().getValue().getAsString();
        int age = employeeObject.getAsJsonObject("id_to_age").entrySet().iterator().next().getValue().getAsInt();

        return new Employee(id, name, age);
    }
}

Проблема с ответом заключается в том, что name и age содержатся внутри объекта JSON whitch переводит на Map в Java, поэтому для его анализа требуется немного больше работы.

Ответ 3

Просто создайте следующий TypeAdapterFactory.

public class ItemTypeAdapterFactory implements TypeAdapterFactory {

  public <T> TypeAdapter<T> create(Gson gson, final TypeToken<T> type) {

    final TypeAdapter<T> delegate = gson.getDelegateAdapter(this, type);
    final TypeAdapter<JsonElement> elementAdapter = gson.getAdapter(JsonElement.class);

    return new TypeAdapter<T>() {

        public void write(JsonWriter out, T value) throws IOException {
            delegate.write(out, value);
        }

        public T read(JsonReader in) throws IOException {

            JsonElement jsonElement = elementAdapter.read(in);
            if (jsonElement.isJsonObject()) {
                JsonObject jsonObject = jsonElement.getAsJsonObject();
                if (jsonObject.has("data")) {
                    jsonElement = jsonObject.get("data");
                }
            }

            return delegate.fromJsonTree(jsonElement);
        }
    }.nullSafe();
}

}

и добавьте его в свой конструктор GSON:

.registerTypeAdapterFactory(new ItemTypeAdapterFactory());

или

 yourGsonBuilder.registerTypeAdapterFactory(new ItemTypeAdapterFactory());