square/moshi

Retrofit / enum class / @Json name specified / @FormUrlEncoded API

nikclayton opened this issue · 1 comments

[I've keyword-stuffed the title so that even if you decide not to do this some poor soul in the same situation has a better chance of finding this solution. I've searched through this repo's issues and PRs and couldn't find anything else discussing this]

I'm working with an API that sends JSON in response to requests, but sometimes expects updates to be POSTed with form URL encoding (https://docs.joinmastodon.org/methods/filters/).

There's an interesting wrinkle with this that I've just become aware of. Using that API as an example I have an enum to represent filter contexts:

enum class FilterContext {
    @Json(name = "home") HOME,
    @Json(name = "notifications") NOTIFICATIONS,
    @Json(name = "public") PUBLIC,
    @Json(name = "thread") THREAD,
    @Json(name = "account") ACCOUNT,
}

This works exactly as you would expect on receiving JSON, ... "context": "home" ... deserializes to FilterContext.HOME correctly.

Naively you might expect that if Moshi's converter factory is correctly added to Retrofit then calling through to an API defined like this:

@FormUrlEncoded
@POST("api/v2/filters")
suspend fun createFilter(
    @Field("title") title: String,
    @Field("context[]") context: List<FilterContext>,  // <-- FilterContext used here
    @Field("filter_action") filterAction: String,
    @Field("expires_in") expiresInSeconds: Int?,
): NetworkResult<Filter>

would cause Retrofit to also use the @Json name property to serialise the FilterContext.

But of course it doesn't, because @FormUrlEncoded is not JSON, so the enum values are sent as uppercase and rejected by the server.

Since I'm loathe to repeat an identical lowercase string representation of the enum I wrote this and added it as an additional converter factory when creating the Retrofit singleton in my app.

It's a singleton converter and converterfactory that uses the value of the @Json(name = ...) property if it exists on the enum, falling back to the normal string representation if it doesn't.

object EnumConstantConverterFactory : Converter.Factory() {
    object EnumConstantConverter : Converter<Enum<*>, String> {
        override fun convert(enum: Enum<*>): String {
            return try {
                enum.javaClass.getField(enum.name).getAnnotation(Json::class.java)?.name
            } catch (_: Exception) {
                null
            } ?: enum.toString()
        }
    }

    override fun stringConverter(
        type: Type,
        annotations: Array<out Annotation>,
        retrofit: Retrofit,
    ): Converter<Enum<*>, String>? {
        return if (type is Class<*> && type.isEnum) EnumConstantConverter else null
    }
}

[Creating a Moshi adapter for the type and calling toJson on the adapter doesn't work for this use case because then the enum value is wrapped in quotes -- correct for JSON, not correct here]

Would the Moshi team be interested in bundling this with Moshi? Either:

  1. As a separate converter factory, mentioned in the docs?
  2. As an option (default off) to the existing converter factory?
  3. Mentioning this as an example of how Moshi's annotations can be used in adjacent contexts?

Absolutely no problem if not; at the very least I hope this writeup is useful to anyone else who encounters a similar issue.

I would say that as-written this is fairly specific. It works for enums, but not classes. It also doesn't honor any JsonAdapters one might be using for enums installed in a Moshi instance.

In Retrofit one of its samples is taking an arbitrary request body converter and automatically adapting it to work as string converter: https://github.com/square/retrofit/blob/ae8c4ee3c7f20bcdaafa64d32541c1f00c2f696c/samples/src/main/java/com/example/retrofit/JsonQueryParameters.java. The sample uses Gson and keys on a @Json annotation, but it will work for any string-based request body converter. This works for classes, but if you try and serialize just an enum you'll get a quoted value, because that's what JSON demands for strings.

I'm not sure there's a perfect solution here that I would feel comfortable shipping in an artifact. I'd be happy to accept something like what you wrote to Retrofit's samples, though.