Skip to content

Envelope

Many backends answer with HTTP 200 regardless of the business outcome, and encode the real result inside the body:

{ "code": 0, "message": "ok", "data": { ... } }

Sandwich classifies such a response as ApiResponse.Success, because the transport layer did succeed. The envelope support lets Sandwich additionally understand the business outcome, so a business failure is classified as ApiResponse.Failure.Error and the payload can be flattened.

ApiEnvelope

Implement ApiEnvelope<T, E> on the response model, where T is the business payload and E is the business error:

data class BaseResponse<T>(
  val code: Int,
  val message: String,
  val data: T?,
) : ApiEnvelope<T?, String> {
  override val isEnvelopeSuccessful: Boolean get() = code == 0
  override val envelopeBody: T? get() = data
  override val envelopeError: String get() = message
}

envelopeBody is only read when isEnvelopeSuccessful is true, and envelopeError only when it is false, so the unused side may throw or return a placeholder.

Automatic classification

ApiEnvelopeMapper is registered on SandwichInitializer.sandwichSuccessMappers by default, so a business failure is demoted to ApiResponse.Failure.Error without any setup:

val response = service.fetchPosters() // ApiResponse<BaseResponse<List<Poster>>>

response.onSuccess {
  // only runs when code == 0
}.onError {
  // runs when code != 0, and `payload` is the envelope's message
}

This also means global operators and global failure mappers observe the business failure, so cross-cutting concerns such as logging or token refresh see a consistent picture.

A response body that does not implement ApiEnvelope passes through untouched, so the default registration has no effect on models that do not opt in. To opt out entirely:

SandwichInitializer.sandwichSuccessMappers -= ApiEnvelopeMapper

unwrap

Automatic classification does not change the type of the response, because the runtime payload must keep agreeing with the declared type. Use unwrap to flatten ApiResponse<BaseResponse<T>> into ApiResponse<T>, typically at the repository boundary:

// service declares the wire type
interface PosterService {
  @GET("posters")
  suspend fun fetchPosters(): ApiResponse<BaseResponse<List<Poster>>>
}

// repository exposes the business type
class PosterRepository(private val service: PosterService) {
  suspend fun posters(): ApiResponse<List<Poster>?> = service.fetchPosters().unwrap()
}

unwrap handles all three cases:

Input Output
Success with a business-successful envelope Success holding envelopeBody
Success with a business-failed envelope Failure.Error holding envelopeError
Failure.Error / Failure.Exception returned as is

The tag of a successful response is preserved, so Retrofit and Ktor extensions such as statusCode and headers keep working after unwrapping.

ApiEnvelopeSpec

When the response model cannot be modified — a generated DTO, or a model owned by another module — describe the envelope contract from the outside with ApiEnvelopeSpec:

data class LegacyResponse(val status: String, val payload: String, val reason: String)

object LegacyResponseSpec : ApiEnvelopeSpec<LegacyResponse, String, String> {
  override fun isEnvelopeSuccessful(envelope: LegacyResponse) = envelope.status == "OK"
  override fun envelopeBody(envelope: LegacyResponse) = envelope.payload
  override fun envelopeError(envelope: LegacyResponse) = envelope.reason
}

val response: ApiResponse<String> = service.fetchLegacy().unwrap(LegacyResponseSpec)

Note that a spec is only consulted by an explicit unwrap(spec) call. Automatic classification relies on the ApiEnvelope interface, because it has to recognize the envelope at runtime.

Non-JSON error bodies

ApiEnvelope is not limited to a code/message/data shape. Any rule that inspects the parsed body works, which covers servers that answer HTTP 200 with an error page:

data class SpreadSheetResponse(val raw: String) : ApiEnvelope<String, String> {
  override val isEnvelopeSuccessful: Boolean get() = !raw.startsWith("<!DOCTYPE html")
  override val envelopeBody: String get() = raw
  override val envelopeError: String get() = "the server returned an HTML error page"
}

Custom success mappers

ApiEnvelopeMapper is a regular ApiResponseSuccessMapper, and you can register your own to run any global rule that may demote a success:

SandwichInitializer.sandwichSuccessMappers += ApiResponseSuccessMapper { response ->
  val data = response.data
  if (data is List<*> && data.isEmpty()) {
    ApiResponse.Failure.Error(payload = "empty result")
  } else {
    response
  }
}

Success mappers run before failure mappers, so a demoted response still flows through global failure mappers.

For a suspending rule, implement ApiResponseSuccessSuspendMapper instead. It is only applied on suspending creation paths such as ApiResponse.suspendOf, because a suspending mapper cannot be awaited from a blocking one.