Go: HTTP-обработчик расчёта калорийности состава — задача с проверкой
Практическая задача на Go по теме «Невалидный состав не создаёт расчёт». Условие содержит контракт функций, предоставленные типы и требования к результату. Используйте выданный шаблон и сохраняйте его публичные объявления. Перед проверкой разберите успешный путь, обработку ошибок и отмену операции, когда она предусмотрена контрактом. Здесь оценивается конкретный компонент сервиса; в полном курсе микросервисов на Go эти компоненты связываются с HTTP, PostgreSQL, gRPC и Kafka.
HTTP-обработчик расчёта калорийности состава
Задача
Редактируйте функцию CalculationHandler в полном шаблоне Go. Сохраните пакет, импорты, предоставленные типы и публичный адаптер. Функцию main добавлять не нужно.
Реализуйте функцию:
func CalculationHandler(catalog Catalog, calculator Calculator) http.HandlerFunc
Функция возвращает HTTP-обработчик, который принимает POST-запрос с JSON-составом ингредиентов, проверяет его, разрешает ингредиенты по каталогу и возвращает суммарную калорийность.
Внешний код передаёт только POST /calculations с Content-Type: application/json; дополнительная маршрутизация и проверки метода, пути или типа содержимого не требуются. catalog и calculator гарантированно ненулевые. Плотности энергии каталога конечны и неотрицательны, результаты калькулятора конечны и невелики; проверять корректность внутренних данных зависимостей и переполнение итогов не нужно.
Предоставленные объявления
Следующие типы, константа и вспомогательная функция уже объявлены — не переобъявлять их:
const MaxCalculationBodyBytes int64 = 1024
type IngredientInput struct {
IngredientID *string `json:"ingredient_id"`
Grams *float64 `json:"grams"`
}
type CalculationRequest struct {
Ingredients []IngredientInput `json:"ingredients"`
}
type CalculationResponse struct {
Calories float64 `json:"calories"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
type Product struct {
KcalPer100g float64
}
type ResolvedIngredient struct {
Grams, KcalPer100g float64
}
type Catalog interface {
Lookup(context.Context, string) (Product, bool)
}
type Calculator interface {
Calculate([]ResolvedIngredient) float64
}
// writeJSON устанавливает Content-Type: application/json,
// записывает status до тела и кодирует value в JSON с завершающим переводом строки.
func writeJSON(w http.ResponseWriter, status int, value any)
Уже доступны импорты: bytes, context, encoding/json, errors, io, math, net/http.
Правила обработки запроса
Ограничение тела. Оберните r.Body через http.MaxBytesReader лимитом MaxCalculationBodyBytes, затем прочитайте тело через io.ReadAll. Ровно 1024 байта допустимы, 1025 — нет. Учитываются фактические байты r.Body, включая пробелы и переводы строк, независимо от ContentLength. Тело запроса нужно закрыть. Сначала завершается ограниченное чтение, поэтому тело сверх лимита получает 413 даже при некорректном начале JSON. Превышение лимита определяется через errors.As с *http.MaxBytesError → HTTP 413, {"error":"request_too_large"}. Любая другая ошибка чтения → HTTP 400, {"error":"invalid_request"}.
Декодирование. Декодируйте прочитанные байты в свежий CalculationRequest. Допустим ровно один JSON-объект: первый Decode разбирает вход, второй вызов того же декодера должен вернуть io.EOF — иначе HTTP 400. Пробельный хвост допустим, второй объект или невалидный хвост — нет. Decoder.More не проверяет конец документа. Неизвестные JSON-поля игнорируются для совместимости; DisallowUnknownFields не используйте. Верхнеуровневые null и {} (пустой объект без непустого состава) недопустимы → HTTP 400, {"error":"invalid_request"}.
Валидация состава. ingredients должен быть непустым массивом. Каждый элемент обязан содержать:
ingredient_id: присутствует, неnull, непустая строка (пробельные строки — допустимые ключи каталога);grams: присутствует, неnull, конечное строго положительное число (дробные значения допустимы).
Отсутствие или null любого из полей, пустой ID, ноль или отрицательная масса → HTTP 400, {"error":"invalid_request"}. Весь состав валидируется до любого обращения к каталогу. Если поздний элемент невалиден, а ранний содержит неизвестный ID — возвращается HTTP 400, Lookup не вызывается.
Разрешение ингредиентов. После полной валидации для каждого элемента вызовите catalog.Lookup с исходным r.Context() и точным ingredient_id. Первый found=false → HTTP 404, {"error":"ingredient_not_found"}; Calculate не вызывается. При повторяющихся ID каждый элемент обрабатывается отдельно в исходном порядке.
Расчёт. Только когда все ингредиенты найдены, передайте упорядоченный []ResolvedIngredient (с исходными Grams и каталожными KcalPer100g) в calculator.Calculate ровно один раз. Верните HTTP 200, {"calories": <результат calculator>}.
Ответы. Все ответы — JSON через writeJSON. Никаких дополнительных полей, диагностик, заголовков Location или сохранённых данных между запросами.
Примеры
Успешный расчёт (oats 380 ккал/100г × 50г + banana 90 ккал/100г × 100г = 280 ккал):
Тело POST /calculations:
{"ingredients":[{"ingredient_id":"oats","grams":50},{"ingredient_id":"banana","grams":100}]}
Ответ 200:
{"calories":280}
Отсутствует grams:
Тело POST /calculations:
{"ingredients":[{"ingredient_id":"oats"}]}
Ответ 400:
{"error":"invalid_request"}
Неизвестный ингредиент:
Тело POST /calculations:
{"ingredients":[{"ingredient_id":"oats","grams":50},{"ingredient_id":"unknown","grams":100}]}
Ответ 404:
{"error":"ingredient_not_found"}
Тело 1025 байт:
Ответ 413:
{"error":"request_too_large"}
Подсказки
- Создайте один
json.NewDecoderповерхbytes.NewReaderот прочитанных ограниченных байтов и используйте тот же экземпляр для обоих вызововDecode: второй должен вернуть именноio.EOF. - Полностью пройдите по всем элементам
ingredientsв одном цикле, проверяя каждое поле, прежде чем перейти к циклу Lookup — иначе порядок ошибок 400 vs 404 будет нарушен.
Запуск на Latorn: редактируйте реализацию в полном шаблоне, сохраняя предоставленные объявления и публичный адаптер RunScenarios. main не требуется. Адаптер принимает массив сценариев и возвращает наблюдения; ожидаемые результаты сравниваются отдельно на сервере. Для собственного запуска аргументы — JSON-массив с одним массивом сценариев: [[...]].
Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.
Отправьте решение, чтобы увидеть результаты тестов.
Связанные задачи
- Go: Принять только полный ответ о последней статье — задача с проверкой
- HTTP-клиент поиска на Go: обработка ответа и ошибок — задача
- Go: Декодирование артефакта из JSON — задача с проверкой
- Go: Обязательная и необязательная зависимость при отказе — задача с проверкой
- Go: HTTP-обработчик поиска задания через JobStore — задача с проверкой
- Go: Контрактный тест обработчика и клиента питания — задача с проверкой
