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

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Связанные задачи