Envelope de resposta

· Padrão API
Juan Kalleo
Juan Kalleo
Backend/API
This page hasn't been translated to English yet — showing the original Portuguese content.

Toda resposta JSON da API sai no mesmo formato — sucesso ou erro, index ou destroy, sem exceção. Um cliente (o front, um teste, um script) nunca precisa adivinhar a forma da resposta pelo endpoint: sempre os mesmos três campos possíveis, status, message, data (sucesso) ou errors (erro).

Como fica na prática

{ "status": "success", "message": "Papeis listados com sucesso", "data": { "id": 1, "nome": "Administrador" } }
{ "status": "error", "message": "não pode ficar em branco", "errors": { "nome": ["não pode ficar em branco"] } }

O código por trás

app/controllers/concerns/json_response.rb:

def render_success(data: nil, message:, status: :ok)
  render json: { status: "success", message: message, data: serialize_for_response(data) }, status: status
end

def render_error(message:, errors: nil, status: :unprocessable_entity, code: nil, details: nil)
  payload = { status: "error", message: message, errors: normalize_errors(errors) }
  payload[:code] = code if code.present?
  payload[:details] = details if details.present?
  render json: payload, status: status
end

Todo controller admin chama um desses dois — nunca render json: cru. É o que garante o formato consistente sem precisar lembrar disso a cada endpoint novo.

Por que destroy também devolve 200 com envelope, não 204

O scaffold padrão do Rails gera destroy respondendo 204 No Content — sem corpo. Esse padrão foi conscientemente descartado aqui: destroy passa pelo mesmo render_success, 200 com {status, message, data} como qualquer outro endpoint. A régua é "todo endpoint desta API responde no mesmo envelope, sem exceção por verbo HTTP" — um cliente que sempre espera {status, message, data} nunca precisa de um if especial só pra DELETE. O teste real reforça isso, não um scaffold esquecido:

test "should destroy a_papel" do
  assert_difference("APapel.count", -1) do
    delete api_v1_admin_a_papel_url(@a_papel), headers: @headers, as: :json
  end
  assert_response :success
end

assert_response :success (200), não :no_content (204) — é a convenção do projeto, verificada, não a herdada por padrão do Rails.

Cuidado real: serializar um registro dentro de um hash

def serialize_for_response(data)
  return data unless data.is_a?(ActiveRecord::Base) || data.is_a?(ActiveRecord::Relation) || (data.is_a?(Array) && data.first.is_a?(ActiveRecord::Base))
  ActiveModelSerializers::SerializableResource.new(data, include: "**")
end

Achado real: se um controller monta a resposta como render_success(data: { papel: @papel, extra: "..." }), o @papel dentro desse hash não passa pelo serializer automaticamente — o data: do render_success só reconhece um registro (ou relation, ou array de registro) no nível mais externo, não aninhado dentro de outro hash. serialize_for_response existe justamente pra cobrir o caso comum (data: @papel ou data: @papeis) sem precisar chamar o serializer à mão em cada controller — mas o caso aninhado ainda exige serializar manualmente antes de montar o hash.

Leitura de apoio