---
title: "REST-шлюз и HTTP-совместимость"
description: "REST поверх proto: grpc-gateway с аннотациями google.api.http против JSON из коробки Connect, GET-запросы через NO_SIDE_EFFECTS, protojson и ошибки."
course: "Connect: современный RPC от Buf"
url: "https://golangmaster.ru/courses/connect/lessons/21-rest/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 20
---

# REST-шлюз и HTTP-совместимость

*Курс «Connect: современный RPC от Buf», урок 21 из 25. ~20 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/connect/lessons/21-rest/*

rest шлюз grpc — классическая задача: proto-бэкенд нужен REST-клиентам, у которых нет protobuf-рантайма. Разберём два пути: grpc-gateway с аннотациями google.api.http — отдельный генератор и реверс-прокси перед сервером, и врождённая HTTP-совместимость Connect — JSON поверх того же хендлера, GET-запросы для процедур без побочных эффектов и ошибки, уже замапленные на HTTP-статусы.

Рано или поздно к proto-сервису приходит клиент, который не умеет protobuf: curl в шпаргалке дежурного, внешний монитор, партнёр с python-requests, браузерная закладка. Классический ответ экосистемы gRPC — rest шлюз grpc: отдельный компонент, принимающий обычный HTTP и JSON и транслирующий их в gRPC-вызовы. Эталонная реализация — grpc-gateway: кодогенерация из аннотаций google.api.http и реверс-прокси рядом с сервером; в поиске его ищут и как grpc gateway без дефиса — проект один и тот же. Connect отвечает на ту же задачу иначе: HTTP-натура встроена в сам протокол — тот же хендлер из третьего урока принимает JSON POST от любого HTTP-клиента, ошибки из шестого урока уже замаплены на статусы, а GET-запросы для процедур без побочных эффектов включаются одной опцией proto. В этом уроке соберём оба пути руками, сравним их по кодогенерации, деплою, стримам и ошибкам — и сформулируем, когда gateway всё ещё правильный выбор, а когда connect rest закрывает вопрос без нового компонента в архитектуре.

### grpc-gateway: прокси из аннотаций

Механика grpc-gateway: в proto рядом с rpc пишется аннотация google.api.http — какой HTTP-метод и путь соответствует процедуре и как поля сообщения раскладываются по пути, query и телу. Генератор protoc-gen-grpc-gateway выдаёт runtime-код: http-роутер, который принимает HTTP-запрос с JSON, собирает из него protobuf-сообщение и делает gRPC-вызов к бэкенду. То есть gateway — полноценный реверс-прокси: свой процесс или sidecar, свой порт, свои таймауты, своя схема ошибок, которую надо трансформировать в HTTP-статусы и не дать ей расползтись с реальностью бэкенда. Практическая деталь: gateway-роутер можно держать и в том же процессе, что и gRPC-сервер — mux соседствует с grpc-сервером через отдельные порты, но это не отменяет второй кодогенерации и второй схемы ошибок. Бонусы честные: точный контроль REST-контракта — красивые пути вроде /v1/notes/42, кастомные префиксы, версии в пути; и генерация swagger/OpenAPI из тех же аннотаций для внешних партнёров. Цена тоже честная: второй стек кодогенерации в CI, второй артефакт в деплое, лишний хоп латентности, стримы gateway не проксирует — только unary, и двойная схема ошибок, которую надо синхронизировать руками. Это инструмент слоя перевода, и использовать его стоит тогда, когда слой перевода действительно нужен.

### Аннотация google.api.http

```go
import "google/api/annotations.proto";

service NoteService {
  rpc GetNote(GetNoteRequest) returns (GetNoteResponse) {
    option (google.api.http) = {
      get: "/v1/notes/{note_id}"
      additional_bindings {
        get: "/v1/notes"
      }
    };
  }
}
```

Классика gateway: GET /v1/notes/42 маршрутизируется в GetNote, поле note_id берётся из пути, вторая привязка отдаёт список. Для создания — post с телом, для удаления — delete. Аннотации живут в proto, поэтому REST-контракт версионируется вместе со схемой, а buf lint из десятого урока проверяет импорт annotations.proto.

**Вопрос:** Что физически представляет собой grpc-gateway в деплое?

- Библиотеку-middleware внутри grpc.Server
- ✅ Отдельный HTTP-сервер-прокси, который принимает JSON и делает gRPC-вызовы к бэкенду
- Режим grpc-go, включаемый опцией сервера
- DNS-алиас на тот же порт, что и gRPC

Это реверс-прокси: сгенерированный роутер живёт в собственном http.Server — отдельный процесс, порт или sidecar, — принимает HTTP с JSON, собирает protobuf-сообщения и ходит на бэкенд gRPC-клиентом. Отсюда вся цена: второй артефакт, второй хоп, вторая схема ошибок — и вся выгода: полный контроль REST-путей и OpenAPI из аннотаций.

### connect rest: JSON из коробки

Connect переворачивает задачу: шлюз не нужен, потому что HTTP — родная среда протокола. connect rest — это когда тот же хендлер принимает обычный POST с JSON: путь процедуры /notes.v1.NoteService/GetNote, тело — сообщение в protojson, ответ — JSON обратно. curl-однострочник вместо клиента, монитор без рантайма, быстрый дебаг на проде — без единой аннотации и без прокси. Ошибки шестого урока уже замаплены на HTTP-статусы: not_found приедет 404-м, unauthenticated — 401-м, resource_exhausted — 429-м, с JSON-телом, где код и сообщение читаются глазами. Браузерный клиент из девятого урока говорит тем же протоколом — connect-web ходит прямо на хендлер, CORS настраивается middleware из девятнадцатого урока; gRPC-шлюзу для браузера понадобился бы ещё и grpc-web-прокси. protojson-нюансы из восемнадцатого урока в силе: int64 и 64-битные целые — строки, enum — строковые имена, camelCase по умолчанию, json_name переименовывает поля в JSON. Отдадим должное: путей вида /v1/notes/42 здесь нет — пути процедурные; это плата за отсутствие аннотаций, и о компенсациях ниже. Ещё одна приятность жанра: запрос можно повторить той же командой из истории shell — идемпотентные чтения не создают дублей, а ответ виден прямо в терминале вместе со статусом; дежурный решает инцидент, не разворачивая клиентов. Платформенный бонус: тот же JSON-жанр слушают мониторы и smoke-тесты пайплайна — им не нужен protobuf-рантайм, только curl и адрес.

### curl к хендлеру без шлюза

```go
curl -s http://localhost:8080/notes.v1.NoteService/GetNote \
  -H "Content-Type: application/json" \
  -d '{"noteId": "42"}'

# ответ:
# {"note": {"noteId": "42", "title": "Первые шаги", "tags": ["go"]}}
```

Connect-жанр: POST на процедурный путь, JSON по protojson-правилам, ответ — JSON с полями в camelCase. Ошибка приедет статусом 404 и телом с кодом not_found — ничего генерировать не нужно. Content-Type и путь определяют жанр: те же правила, что разбирал четвёртый урок.

### GET-запросы и кэширование

POST на чтение режет кэширование: браузер, CDN и обратный прокси по умолчанию не кэшируют POST. Connect даёт управляемый ответ: в proto у процедуры ставится option idempotency_level = NO_SIDE_EFFECTS — и для Connect-жанра клиент начинает слать GET с сообщением, закодированным в query-строке, а хендлер принимает его без дополнительных флагов: генерация, клиент и сервер читают опцию из дескриптора. GetNote становится честным GET /notes.v1.NoteService/GetNote?noteId=42 — HTTP-кэши между клиентом и сервером работают по стандартным правилам, повторная отправка безопасна по определению. Ограничения умные. Первое: работает только Connect-жанр — gRPC и gRPC-Web в принципе не знают GET-вызовов и продолжают POST. Второе: query-кодирование накладывает размерные пределы URL, поэтому NO_SIDE_EFFECTS ставят на лёгкие read-процедуры с простыми полями, а не на запросы с большими фильтрами и вложенными структурами. Третье: идемпотентность — семантическое заявление, а не магия; PutNote с естественной идемпотентностью можно пометить тоже, если повтор возвращает тот же результат. Это противовес аннотациям gateway: не кастомный путь, а стандартный процедурный GET с кэшируемостью.

### NO_SIDE_EFFECTS и GET-вызов

```go
// proto/notes/v1/notes.proto
rpc GetNote(GetNoteRequest) returns (GetNoteResponse) {
  option idempotency_level = NO_SIDE_EFFECTS;
}

// вызов без клиента и без тела:
curl -s "http://localhost:8080/notes.v1.NoteService/GetNote?noteId=42"
```

Пометка ставится в proto, кодогенерация не меняется, connect-клиент из пятого урока шлёт GET автоматически, а хендлер декодирует query в сообщение. Если кэширование входит в план — выставляй Cache-Control сознательно: хендлер сам заголовков кэша не добавляет, стандартное поведение HTTP оставляет решение тебе.

### Что увидит middleware на сервере?

```go
// proto: option idempotency_level = NO_SIDE_EFFECTS на GetNote
res, err := client.GetNote(ctx,
        connect.NewRequest(&notesv1.GetNoteRequest{NoteId: "42"}))
// вопрос: что зафиксирует withLogging из девятнадцатого урока?
```

**Что выведет программа?**

- POST с JSON-телом: жанр Connect всегда POST
- ✅ GET с query noteId=42 и без тела
- DELETE с телом: опция меняет только семантику
- 405 Method Not Allowed: GET-вызовы запрещены

NO_SIDE_EFFECTS переключает Connect-жанр на GET: сообщение уезжает в query по json-именам полей, тело пустое. Middleware видит метод GET и путь процедуры — лог и метрика записывают чтение как чтение. Для gRPC-жанра тот же вызов остался бы POST: GET-режим знает только Connect-протокол из четвёртого урока.

### JSON-канон и ошибки на HTTP

JSON-слой Connect — не своя выдумка, а protojson-канон protobuf: camelCase-имена по умолчанию, json_name из восемнадцатого урока переименовывает поле для JSON, 64-битные целые — строки, потому что JavaScript не умеет точные int64, enum — строковые имена, bytes — base64, отсутствующее поле — отсутствие ключа, а не null. REST-клиент на python-requests читает ответ без сюрпризов, если его автор помнит про int64-строки; ошибка приходит HTTP-статусом и JSON-телом с кодом — те же коды, что gRPC-клиент видит в заголовках grpc-status. Сравнение с gateway честное: у gateway трансляция ошибок настраивается руками и часто расползается с реальностью бэкенда после рефакторингов; у Connect это один код-путь в хендлере, который обслуживает все жанры сразу. curl-режим делает JSON-слой ещё и инструментом диагностики: любой ответ читается глазами без декодера, и тот же вызов, что в шпаргалке дежурного, служит контрактом для монитора. Если нужны документы API — пятнадцатый урок показывал генерацию OpenAPI из дескрипторов, без аннотаций. Отдельный жанр задач — отладка чужих интеграций: партнёр присылает скриншот ответа, и JSON-канон позволяет понять глазами, что поле пришло строкой, а не числом, без декодера и без repro-скрипта; в gRPC-мире для того же понадобился бы дамп трафика с декодированием.

### grpc-gateway против Connect

| Критерий | grpc-gateway | Connect |
| --- | --- | --- |
| Кодогенерация | второй генератор protoc-gen-grpc-gateway и runtime | тот же плагин connect-go, файлы рядом |
| Деплой | отдельный процесс или sidecar, второй порт | тот же хендлер и порт, ноль новых компонентов |
| Пути | кастомные REST: /v1/notes/{note_id} | процедурные: /notes.v1.NoteService/GetNote |
| GET и кэш | аннотации http.get | idempotency_level = NO_SIDE_EFFECTS |
| Стримы | не проксируются, только unary | работают все три вида из восьмого урока |
| Ошибки | свои правила трансляции, держать вручную | коды в статусы из коробки, один источник |
| Браузер | HTTP JSON из коробки | connect-web из девятого урока, CORS на mux |
| Документы API | swagger из аннотаций | OpenAPI из дескрипторов, пятнадцатый урок |

### Когда gateway всё ещё выбирают

Честный ответ на «зачем тогда grpc-gateway»: когда внешний REST-контракт уже существует и его нельзя менять. Партнёрская интеграция со спекой GET /v1/notes/{id}?include=tags, платформенная политика «публичные API только в /v1», OpenAPI-документ, сгенерированный из аннотаций и розданный клиентам, — в этих случаях gateway легален и полезен: он ставит слой перевода между стабильным REST-контрактом и живущей своей жизнью proto-схемой. Второй легальный случай — тяжёлые требования к путям и методам, которые опциями proto не выразить. Гибрид тоже рабочий: connect-хендлер на общем mux из девятнадцатого урока обслуживает RPC и браузерных клиентов, а gateway смонтирован рядом на те две-три процедуры, у которых есть внешний REST-контракт; либо ещё проще — пара обычных http.HandlerFunc-роутов рядом с RPC, если внешний контракт тривиален, с r.PathValue для сегментов пути. Такие соседи-роуты живут под тем же TLS и тем же логированием, что и RPC, и не требуют нового ingress-правила. Антипаттерн один: тянуть gateway ради «чтобы был», когда реальных REST-клиентов ноль — тогда это второй артефакт, второй порт и вторая схема ошибок без единой пользы.

**Вопрос:** Сервис живёт на Connect, внешних REST-контрактов нет, партнёры могут качать SDK. Нужен ли grpc-gateway, чтобы curl мог вызвать GetNote?

- Да, без шлюза HTTP-клиенты не работают
- ✅ Нет: Connect-жанр принимает JSON POST и GET с NO_SIDE_EFFECTS из любого HTTP-клиента
- Да, но только для GET-процедур
- Нет, если включить gRPC-Web на сервере

curl, requests, axios — любые HTTP-клиенты говорят Connect-жанром: POST JSON на процедурный путь, а для процедур с NO_SIDE_EFFECTS — GET с query. Шлюз нужен только под внешний кастомный REST-контракт; здесь его нет — значит, нет и шлюза, и его кодогенерации, и его порта.

### Включи GET для процедуры

Помети GetNote как запрос без побочных эффектов, чтобы connect-клиент слал GET.

```go
rpc GetNote(GetNoteRequest) returns (GetNoteResponse) {
  option ___ = ___;
}

// вызов без тела:
// GET /notes.v1.NoteService/GetNote?noteId=42
```

Подсказка: Опция proto idempotency_level со значением NO_SIDE_EFFECTS включает GET-кодирование для Connect-жанра; gRPC-жанр остаётся POST, браузерный клиент получает кэшируемый вызов.

Правильные ответы в пропусках: `idempotency_level`, `NO_SIDE_EFFECTS`

### Путь GET-вызова

Расставь путь вызова GetNote от connect-клиента до ответа при NO_SIDE_EFFECTS.

Правильный порядок строк:

1. `Клиент читает NO_SIDE_EFFECTS из дескриптора процедуры`
2. `Сообщение кодируется в query-строку по json-именам полей`
3. `Уходит GET /notes.v1.NoteService/GetNote?noteId=42 без тела`
4. `Кэш на пути решает: свежая копия возвращается без похода до сервера`
5. `Middleware из девятнадцатого урока видит GET и путь процедуры`
6. `Хендлер декодирует query в GetNoteRequest и зовёт имплементацию`
7. `Ответ уезжает JSON-ом, коды ошибок замаплены на статусы`
8. `HTTP-кэш может сохранить ответ по заголовкам, если они выставлены`

> **GET — только лёгким read-процедурам.** Query-кодирование ограничено длиной URL и простотой полей: большие фильтры, глубокие вложенные структуры и сотни repeated-элементов не поедут в строке запроса — оставь их POST. Не ставь NO_SIDE_EFFECTS на процедуры с побочными эффектами ради кэша: прокси честно поверит опции и отдаст кэшированный ответ, повторные списания и дублирующие создания — твоя ответственность. И помни: GET-режим знает только Connect-жанр, gRPC-клиенты всегда POST.

> **Три GET-роута дешевле шлюза.** Если внешний мир просит два-три простых GET, не заводи ни gateway, ни аннотации: обычные http.HandlerFunc рядом с RPC на общем mux из девятнадцатого урока закрывают вопрос стандартной библиотекой. Шлюз оправдан, когда внешний REST-контракт большой, версионированный и живёт по своим правилам — тогда он слой перевода, а не украшение архитектуры.

> **Как проходить этот курс.** Код урока — настоящий proto и настоящий shell: аннотация, NO_SIDE_EFFECTS и curl-вызовы воспроизводятся в проекте из третьего урока один в один. Браузерная песочница сетевых вызовов не исполняет, поэтому исполняемых блоков в курсе нет — интерактивы работают на странице и дают XP, а HTTP-поверхность проверяй curl-ом на локальном порту.

### Карточки: REST поверх proto

- **Что такое grpc-gateway** — Реверс-прокси из кодогенерации: аннотации google.api.http в proto, protoc-gen-grpc-gateway, HTTP-сервер с JSON, транслирующий вызовы в gRPC. Свой процесс, порт и схема ошибок.
- **connect rest из коробки** — Хендлер принимает POST JSON на процедурный путь /pkg.Service/Method по protojson-канону; ошибки замаплены на HTTP-статусы. Ноль аннотаций, ноль прокси, один порт.
- **GET-вызовы** — option idempotency_level = NO_SIDE_EFFECTS: connect-клиент шлёт GET с сообщением в query, HTTP-кэши работают. Только Connect-жанр; gRPC и gRPC-Web всегда POST.
- **protojson-нюансы** — camelCase по умолчанию, json_name переименовывает, 64-битные целые — строки, enum — имена, bytes — base64, отсутствующее поле не сериализуется вовсе.
- **Стримы и шлюз** — grpc-gateway unary-only, стримы не проксирует; connect-хендлер стримит все три вида из восьмого урока — браузерному стрим-клиенту прокси не нужен.
- **Когда нужен gateway** — Только под существующий внешний REST-контракт: кастомные пути /v1, розданный OpenAPI, платформенная политика. Иначе — curl и http.HandlerFunc-соседи на общем mux.

### Что дальше по курсу

HTTP-поверхность закрыта с двух сторон: из коробки — процедурные JSON-вызовы и GET-кэширование, по требованию — gateway под внешний контракт. Дальше — деплой: Docker, ingress, HTTP/2 на границе и TLS, где JSON-поверхность и gRPC-жанры встречаются с реальным трафиком. Для повторения: жанры и маршрутизация по Content-Type — в [четвёртом уроке](/courses/connect/lessons/04-protokoly/), ошибки и статусы — в [шестом](/courses/connect/lessons/06-oshibki/), браузерный клиент и CORS — в [девятом](/courses/connect/lessons/09-brauzernye/), json_name и совместимость схемы — в [восемнадцатом](/courses/connect/lessons/18-versii/), mux и http.HandlerFunc-соседи — в [девятнадцатом](/courses/connect/lessons/19-net-http/). Программа всех двадцати пяти уроков — на [странице курса](/courses/connect/).

## Коротко: что запомнить

- rest шлюз grpc в классике — grpc-gateway: аннотации google.api.http, protoc-gen-grpc-gateway, реверс-прокси с JSON перед gRPC-бэкендом.
- connect rest не требует шлюза: тот же хендлер принимает JSON POST на процедурный путь, protojson-канон, ошибки уже статусы.
- idempotency_level = NO_SIDE_EFFECTS включает GET с query для Connect-жанра — HTTP-кэши работают; gRPC-жанр всегда POST.
- protojson: camelCase, json_name, int64-строки, enum-имена, base64 для bytes, отсутствующее поле не сериализуется.
- Gateway легален под существующий внешний REST-контракт: кастомные пути, OpenAPI из аннотаций, платформенная политика.
- Стримы gateway не проксирует; connect-хендлер стримит из коробки — браузерному клиенту прокси не нужен.
- Тривиальный внешний GET — обычные http.HandlerFunc рядом с RPC на общем mux; шлюз — слой перевода, а не украшение.
- Браузерная граница: connect-web ходит прямо на хендлер с CORS-middleware из девятого урока, grpc-web-прокси не нужен.

## Домашнее задание

Собери обе HTTP-поверхности и сравни их руками. Шаг первый: к notes-сервису из третьего урока добавь import annotations.proto и аннотацию google.api.http для GetNote, включи protoc-gen-grpc-gateway в кодогенерацию, подними gateway рядом и проверь curl-ом GET /v1/notes/42 — путь, query и ошибку 404 при неизвестном идентификаторе. Шаг второй: та же процедура через Connect — curl с POST JSON на процедурный путь, затем поставь option idempotency_level = NO_SIDE_EFFECTS и повтори вызов GET-ом с query; зафиксируй, что код клиента из пятого урока не изменился ни на строку. Шаг третий: сравни ошибки — добей not_found в обоих путях, сравни тело JSON и то, что видит gRPC-клиент в заголовках; выпиши, где схема расползается, а где один источник. Шаг четвёртый: замерь латентность двух хопов gateway против одного хопа connect-хендлера на сотне вызовов curl-ом. Шаг пятый: напиши один http.HandlerFunc-сосед /v1/notes/{id} с r.PathValue на общем mux из девятнадцатого урока и реши честно: нужен ли тебе gateway как слой перевода или хватает JSON-жанра и пары HandlerFunc — запиши вывод и критерии в заметки проекта.

## Вопросы и ответы по уроку

### Connect заменяет grpc-gateway полностью?

Только там, где не нужен кастомный REST-контракт. Процедурные пути и NO_SIDE_EFFECTS закрывают внутренние, мониторинговые и мобильные потребности; под партнёрскую спеку с путями /v1 и розданным OpenAPI gateway остаётся честным слоем перевода между стабильным контрактом и живущей своей жизнью схемой.

### Как отдавать Swagger или OpenAPI?

Два источника правды: из аннотаций gateway — генератор openapiv2, из proto-дескрипторов — конвейер из пятнадцатого урока без аннотаций. Выбирай один и держи документы в CI, чтобы они не отстали от схемы; расползшийся OpenAPI хуже отсутствующего.

### Можно ли комбинировать оба пути?

Да, на общем mux из девятнадцатого урока: connect-хендлер для RPC и браузерных клиентов, gateway или пара HandlerFunc для внешних REST-путей. Главное — один источник истины по ошибкам и статусам: пусть REST-слой не выдумывает свои коды, а наследует их из имплементации.
