package httpapi import ( "net/http" "net/textproto" "strings" ) // Условный запрос читающих маршрутов. Помощник общий намеренно: тот же ответ // понадобится точкам и MCP, а протокол здесь ровно такой, каким его описывает // HTTP, — второй его экземпляр разошёлся бы с первым в мелочи вроде слабого // сравнения. // // ГРАНИЦА, которую обязан знать следующий потребитель: метка действительна // только в пределах одного адреса ресурса. У каталога ответ зависит лишь от // состояния витрины, поэтому версии достаточно; у Read API точек ответ есть // функция параметров запроса, а у MCP адреса нет вовсе — там в метку обязана // входить канонизированная форма запроса, иначе «не изменилось» ответит на // другой набор данных. // etag собирает метку HTTP из области действия и версии ответа. // // Область — параметр, а не забота вызывающего: она и есть то, что помощник // обязан не дать забыть. Метка действительна в пределах ОДНОГО ресурса, и // маршрут, чей ответ зависит от параметров запроса (Read API точек) или у // которого адреса нет вовсе (MCP), кладёт сюда их канонизированную форму. // Прозой это уже было написано — и прозу компилятор не проверяет: `etag(v)` у // второго маршрута собрался бы и отдал `304` на чужой набор данных. // // Метка СЛАБАЯ, и это не осторожность. Ответ есть функция не только снимка, но // и горизонта измерения (`Now()` плюс час), а горизонт едет вместе с часами: // пока в витрине нет меток из будущего, ход часов ответ не двигает, но метки из // будущего в ней возможны — сбитые часы телефона, чужое тело в приёме. Слабая // метка это допускает, сильная обещала бы побайтовое равенство, которого в этом // случае нет. На исход `304` форма не влияет: `If-None-Match` сравнивается // слабо в любом случае. func etag(scope, version string) string { if version == "" { return "" } return `W/"` + scope + "." + version + `"` } // notModified отвечает, покрывает ли условие запроса текущую метку. // // Сравнение слабое: `W/"x"` и `"x"` — одна и та же метка, так предписывает // HTTP для `If-None-Match`. Звёздочка совпадает с любой существующей меткой. // // Неразбираемое значение условия — не отказ, а невыполненное условие: клиент, // приславший мусор, получает данные, а не `400`. Пустая метка (подписать ответ // нечем) не совпадает ни с чем, включая звёздочку: подтверждать неизменность // нечем. // // Алгоритм повторяет `net/http/fs.go` (`scanETag`, `etagWeakMatch`, // `checkIfNoneMatch`): там он есть, но неэкспортирован, и копия дешевле // зависимости. Копия ПОЛНАЯ, включая сканер: резать список по запятой нельзя — // запятая законный символ внутри метки, а в метку читающего маршрута once // попадёт канонизированная форма запроса, где запятая естественна. Тогда `304` // перестал бы срабатывать вообще, и симптом («условный запрос не экономит») // увёл бы отладку в маршрут, а не в помощника. func notModified(header []string, tag string) bool { if tag == "" { return false } for _, line := range header { for { line = textproto.TrimString(line) if line == "" { break } if line[0] == ',' { line = line[1:] continue } if line[0] == '*' { // Звёздочка совпадает с ЛЮБОЙ существующей меткой, а не с любым // состоянием: метки нет — условие не выполнено, и клиент со // звёздочкой получает данные, а не вечный `304`. return true } candidate, remain := scanETag(line) if candidate == "" { // Нечитаемая метка прекращает разбор строки: так делает stdlib, // и это не отказ — условие просто не выполнено. break } if strings.TrimPrefix(candidate, `W/`) == strings.TrimPrefix(tag, `W/`) { return true } line = remain } } return false } // scanETag откусывает метку от начала строки и отдаёт остаток. Копия // `net/http/fs.go`; диапазоны символов — `etagc` из RFC 9110, в них ВХОДИТ // запятая. func scanETag(s string) (tag, remain string) { s = textproto.TrimString(s) start := 0 if strings.HasPrefix(s, "W/") { start = 2 } if len(s[start:]) < 2 || s[start] != '"' { return "", "" } for i := start + 1; i < len(s); i++ { c := s[i] switch { case c == 0x21 || c >= 0x23 && c <= 0x7E || c >= 0x80: // шум внутри метки — законные символы case c == '"': return s[:i+1], s[i+1:] default: return "", "" } } return "", "" } // writeNotModified отвечает `304`: та же метка, никакого тела. // // Представленческие заголовки снимаются — так делает и stdlib // (`net/http/fs.go`, `writeNotModified`) со ссылкой на RFC 9110: у ответа без // тела нечего описывать, а `Content-Length`, доживший до `304`, вводит в // заблуждение любой кеш. Тело рантайм и так не пропустит // (`bodyAllowedForStatus`), но полагаться на это значило бы получать // проглоченную ошибку записи вместо ответа. func writeNotModified(w http.ResponseWriter, tag string) { h := w.Header() h.Del("Content-Type") h.Del("Content-Length") h.Del("Content-Encoding") setReadHeaders(w, tag) w.WriteHeader(http.StatusNotModified) } // setReadHeaders вешает на ответ чтения метку и правило кеширования. // // `private, no-cache` означает «кешируй, но каждый раз спрашивай»: ровно то, // ради чего заведена метка. Заголовок обязателен, а не желателен, — без него // промежуточный кеш вправе решить по эвристике, что выгрузку истории здоровья // можно подержать у себя. func setReadHeaders(w http.ResponseWriter, tag string) { w.Header().Set("Cache-Control", "private, no-cache") if tag != "" { w.Header().Set("ETag", tag) } }