Go API отвергает application/json с charset: разбираем Content-Type
Собственный клиент отправляет Content-Type: application/json и проходит проверку API. Другой SDK добавляет charset=utf-8 и неожиданно получает 415. Обработчик сравнивает строку заголовка с application/json целиком. В итоге допустимый параметр превращается в отказ, хотя тело осталось обычным JSON.
Разделите тип представления и его параметры
Content-Type содержит media type и параметры. Сравнение всей строки заставляет приложение самостоятельно учитывать пробелы, регистр и кавычки. strings.HasPrefix ещё опаснее: оно примет application/jsonp или application/json-invalid. Для разбора в стандартной библиотеке есть mime.ParseMediaType. Функция возвращает нормализованный тип и map параметров, а ошибочную грамматику отделяет от корректного другого типа.
У конкретного маршрута должен быть собственный набор принимаемых представлений. В примере разрешён application/json с необязательным charset=utf-8. XML, другой charset, неизвестные параметры и испорченный заголовок отвергаются. Это сознательно строгая политика небольшого API. Если параметр не имеет значения для вашего контракта, его можно игнорировать, но решение должно быть явным.
Проверьте маршрут через HTTP-интерфейс
Программа строит пять запросов и пропускает их через один handler. Проверка Content-Type происходит до чтения тела. Корректный тип даёт 204, остальные варианты получают 415 Unsupported Media Type. Вариант с верхним регистром и quoted charset тоже проходит: синтаксический разбор позволяет не придумывать отдельные строковые исключения.
package main
import (
"fmt"
"mime"
"net/http"
"net/http/httptest"
"strings"
)
func accepts(value string) bool {
kind, params, err := mime.ParseMediaType(value)
if err != nil || kind != "application/json" {
return false
}
for key, value := range params {
if key != "charset" || !strings.EqualFold(value, "utf-8") {
return false
}
}
return true
}
func handler(w http.ResponseWriter, r *http.Request) {
if !accepts(r.Header.Get("Content-Type")) {
http.Error(w, "use application/json", 415)
return
}
w.WriteHeader(204)
}
func main() {
for _, kind := range []string{"application/json", `APPLICATION/JSON; charset="UTF-8"`, "application/jsonp", "application/json; charset=windows-1251", "application/json; broken"} {
r := httptest.NewRequest("POST", "/items", strings.NewReader(`{}`))
r.Header.Set("Content-Type", kind)
w := httptest.NewRecorder()
handler(w, r)
fmt.Println(w.Code)
}
}
Вывод 204/204/415/415/415 показывает принятый базовый тип и параметры. Проверьте также пустой Content-Type. Для публичного API обычно полезнее сообщить клиенту требуемый формат, чем угадывать по первому символу тела. При этом выбранный ответ на отсутствие заголовка — часть вашего контракта: не все внутренние интеграции исторически передают его.
Проверка заголовка не проверяет JSON
После принятия типа прочитайте тело с лимитом и разберите ожидаемую структуру. Заголовок может обещать JSON, а тело содержать HTML от промежуточного прокси. Неверный формат тела обычно возвращает 400, тогда как неподдерживаемое представление — 415. Разделение помогает клиенту понять, что исправлять: сериализацию данных или заголовок.
Разбор через Decoder тоже требует завершения входа. Один успешный Decode может оставить второе значение JSON непрочитанным. Для маршрута одного объекта проверьте конец потока, как в разборе второго JSON-объекта. Проверка типа, ограничение размера и проверка структуры выполняются до бизнес-операции, чтобы отказ не сопровождался уже созданной записью.
Content-Encoding отвечает на другой вопрос: как закодированы байты тела, например сжаты ли они gzip. Параметр charset не включает распаковку. Если маршрут принимает сжатые запросы, проектируйте отдельные лимиты для передачи и распакованного содержимого. Иначе маленькое сжатое тело может потребовать слишком много памяти после чтения.
У application/problem+json, application/merge-patch+json и vendor media types есть собственный смысл. Не принимайте автоматически любой суффикс +json только потому, что он кодируется JSON. PATCH с Merge Patch ожидает другую семантику, чем обычное полное представление. Список поддерживаемых media types должен следовать операциям API, а не одной удобной регулярке.
Закрепите ожидаемые статусы в контрактных тестах клиента и сервера. После обновления SDK полезно увидеть, что он действительно отправляет: иногда параметр или тип добавляет транспортный слой. Общий middleware удобен для однотипных JSON-маршрутов, но загрузку файла или streaming endpoint через него проводить не нужно. Для каждого представления оставьте понятный путь принятия и понятную причину отказа.
Согласуйте и формат ответа об ошибке
Клиенту легче исправить запрос, когда 415 имеет стабильный машинный код, например unsupported_media_type, и короткое публичное сообщение с допустимым типом. Не возвращайте исходную строку заголовка без необходимости: она полностью контролируется отправителем. Если остальные ошибки API представлены JSON, оформите эту ветку тем же способом и заранее подготовьте её тело. Тогда инфраструктурный middleware не создаст для одного статуса неожиданное текстовое представление. В примере http.Error оставлен для наглядности проверки статуса; при интеграции замените его общим ответом вашего API и закрепите Content-Type тестом.
Продолжите до взаимодействия сервисов
Принимаемый media type нужно закрепить вместе со схемой тела и публичными ошибками. В платном курсе микросервисов на Go вы будете проектировать HTTP- и gRPC-контракты, проверять их поведение при отказах и планировать совместимые изменения. Это помогает выпускать клиент и сервер в разное время, сохраняя понятный протокол. Откройте программу курса «Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker».
Продолжить на практике
- Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗
От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.
